on master help sign in
106 files changed+15409−3732
since splitsnapshots
unifiedsplit
.cargo/config.toml+5 .github/workflows/build.yml+181−5 .gitignore+12−3 AGENTS.md+119 AUDIT-DEEP.md+230 AUDIT-FFI-PHONEMIC-APPLE.md+470 AUDIT-FRESH.md+385 AUDIT-NEW.md+816 CLAUDE.md+1−54 Cargo.lock+2378 Cargo.toml+54 Justfile+49 PROTOCOL-REVIEW.md+416 PROTOCOL.md+243−60 README.md+37−11 apple/SkrepkaApp/Core.swift+98 apple/SkrepkaApp/Effects.swift+428 apple/SkrepkaApp/Forms.swift+198 apple/SkrepkaApp/Info-iOS.plist+49 apple/SkrepkaApp/PhotoPicker.swift+90 apple/SkrepkaApp/QRView.swift+149 apple/SkrepkaApp/SkrepkaApp.swift+166 apple/SkrepkaApp/Views.swift+390 apple/project.yml+71 client/ApiClient.fs−101 client/App.fs−732 client/AppTypes.fs−149 client/Assets/Fonts/OpenSans-Regular.ttf client/Assets/Fonts/OpenSans-Semibold.ttf client/Assets/Fonts/Unbounded-Black.ttf client/Assets/Fonts/Unbounded-Bold.ttf client/Assets/Fonts/Unbounded-ExtraBold.ttf client/Assets/Fonts/Unbounded-ExtraLight.ttf client/Assets/Fonts/Unbounded-Light.ttf client/Assets/Fonts/Unbounded-Medium.ttf client/Assets/Fonts/Unbounded-Regular.ttf client/Assets/Fonts/Unbounded-SemiBold.ttf client/Assets/Images/left.png client/Assets/Images/logo.png client/Assets/Images/send.png client/Assets/Images/user.png client/Buttons.fs−209 client/Constants.fs−16 client/Crypto.fs−245 client/Keyboard.fs−11 client/Labels.fs−27 client/Phonemic.fs−143 client/Platform/Desktop/AppIcon.icns client/Platform/Desktop/Info.plist−24 client/Platform/Desktop/Program.fs−18 client/Platform/iOS/AppDelegate.fs−61 client/Platform/iOS/Entitlements.plist−6 client/Platform/iOS/Info.plist−43 client/Platform/iOS/PhotoPicker.fs−50 client/Platform/iOS/QRScanner.fs−113 client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json−23 client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-120.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-152.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-167.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-180.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-20.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-29.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-58.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-60.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-80.png client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-87.png client/Platform/iOS/Resources/Assets.xcassets/Contents.json−3 client/Platform/iOS/Resources/LaunchScreen.storyboard−25 client/Protocol.fs−110 client/Skrepka.app/Contents/Info.plist−24 client/Skrepka.app/Contents/MacOS/Skrepka−1 client/Skrepka.app/Contents/Resources/AppIcon.icns client/Skrepka.fsproj−138 client/Skrepka.sln−28 client/Store.fs−369 client/Styles.fs−96 client/TextFields.fs−48 client/ViewAddContact.fs−70 client/ViewChat.fs−172 client/ViewConversations.fs−101 client/ViewEditProfile.fs−41 client/ViewSettings.fs−57 client/ViewSetup.fs−30 client/msbuild.binlog client/native/iossimulator-arm64/libsodium.a client/native/maccatalyst-arm64/libsodium.a client/run-ios.sh−31 client/run-mac.sh−6 core/Cargo.toml+60 core/boltffi.toml+24 core/src/app.rs+5027 core/src/bin/codegen.rs+41 core/src/crypto.rs+894 core/src/ffi.rs+96 core/src/lib.rs+10 core/src/model.rs+255 core/src/phonemic.rs+289 core/src/protocol.rs+355 dotnet-tools.json−13 install.sh+186−14 server.knot+1128−250 server.o skrepka.service+9−1
.cargo/config.tomlnew file+5
@@ -0,0 +1,5 @@
1+# Pin the iOS/macOS deployment targets so the C deps (zstd-sys) and rustc agree
2+# on the minimum OS version — otherwise the linker errors on a version mismatch.
3+[env]
4+IPHONEOS_DEPLOYMENT_TARGET = "17.0"
5+MACOSX_DEPLOYMENT_TARGET = "11.0"
.github/workflows/build.yml+181−5
@@ -6,6 +6,65 @@ on:
66 pull_request:
77
88jobs:
9+ core:
10+ name: Core (test + clippy)
11+ runs-on: ubuntu-latest
12+ steps:
13+ - uses: actions/checkout@v4
14+
15+ - uses: dtolnay/rust-toolchain@stable
16+ with:
17+ components: clippy
18+
19+ - uses: Swatinem/rust-cache@v2
20+
21+ - name: Test
22+ run: cargo test -p skrepka_core
23+
24+ - name: Clippy
25+ run: cargo clippy -p skrepka_core --all-targets -- -D warnings
26+
27+ # The codegen bin (src/bin/codegen.rs) is behind the `codegen` feature, so
28+ # the default clippy run never compiles it — a typegen break would only
29+ # surface at `just generate` time.
30+ - name: Clippy (codegen feature)
31+ run: cargo clippy -p skrepka_core --features codegen -- -D warnings
32+
33+ ios:
34+ name: iOS shell
35+ runs-on: macos-14
36+ steps:
37+ - uses: actions/checkout@v4
38+
39+ - uses: dtolnay/rust-toolchain@stable
40+ with:
41+ targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
42+
43+ - uses: Swatinem/rust-cache@v2
44+
45+ - uses: extractions/setup-just@v2
46+
47+ - name: Install tooling
48+ run: |
49+ cargo install boltffi_cli
50+ brew install xcodegen
51+
52+ # typegen + pack + project: regenerates the Swift types, builds the core
53+ # xcframework, and emits the xcodeproj the build step below compiles.
54+ - name: Generate
55+ run: just generate
56+
57+ # -skipPackagePluginValidation matches the Justfile's `build` recipe: the
58+ # generated SPM packages carry plugins, which xcodebuild otherwise blocks
59+ # on with an interactive trust prompt.
60+ - name: Build app
61+ run: |
62+ xcodebuild -project apple/SkrepkaApp.xcodeproj \
63+ -scheme SkrepkaApp-iOS \
64+ -destination 'generic/platform=iOS Simulator' \
65+ -skipPackagePluginValidation \
66+ build
67+
968 build:
1069 strategy:
1170 fail-fast: false
@@ -17,7 +76,10 @@ jobs:
1776 - runner: ubuntu-24.04-arm
1877 name: linux-arm64
1978 knot-platform: linux-aarch64
20− - 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
2183 name: macos-arm64
2284 knot-platform: macos-aarch64
2385 runs-on: ${{ matrix.runner }}
@@ -26,8 +88,7 @@ jobs:
2688
2789 - name: Download knot compiler
2890 run: |
29− version=$(basename "$(curl -sI -o /dev/null -w '%{redirect_url}' \
30− https://github.com/ilyakooo0/knot/releases/latest)")
91+ version="2026.6.26.1947"
3192 tarball="knot-${version}-${{ matrix.knot-platform }}.tar.gz"
3293 curl -fsSL "https://github.com/ilyakooo0/knot/releases/download/${version}/${tarball}" \
3394 | tar xz
@@ -53,13 +114,106 @@ jobs:
53114 - name: Verify binary
54115 run: file server
55116
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 346-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 346-hex-char (173-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+
56210 - uses: actions/upload-artifact@v4
57211 with:
58212 name: server-${{ matrix.name }}
59213 path: server
60214
61215 release:
62− needs: build
216+ needs: [core, ios, build]
63217 if: github.ref == 'refs/heads/master' && github.event_name == 'push'
64218 runs-on: ubuntu-latest
65219 permissions:
@@ -71,9 +225,17 @@ jobs:
71225 with:
72226 path: repo
73227
228+ # Commit timestamp to the second, plus the short SHA. Minute resolution
229+ # alone collides when two commits land in the same minute (a rebase-push,
230+ # or a merge right after a direct push), and a duplicate tag makes the
231+ # release step fail — or worse, silently overwrite the previous release's
232+ # assets. The SHA makes the tag unique even at identical timestamps.
74233 - name: Generate version
75234 id: version
76− run: echo "tag=$(git -C repo log -1 --format='%cd' --date='format:%Y.%m.%d.%H%M')" >> "$GITHUB_OUTPUT"
235+ run: |
236+ ts=$(git -C repo log -1 --format='%cd' --date='format:%Y.%m.%d.%H%M%S')
237+ sha=$(git -C repo rev-parse --short=7 HEAD)
238+ echo "tag=${ts}-${sha}" >> "$GITHUB_OUTPUT"
77239
78240 - name: Prepare release assets
79241 run: |
@@ -85,6 +247,20 @@ jobs:
85247 done
86248 chmod +x server-*
87249
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+
88264 - uses: softprops/action-gh-release@v2
89265 with:
90266 tag_name: ${{ steps.version.outputs.tag }}
.gitignore+12−3
@@ -4,8 +4,17 @@ server
44*.db-journal
55*.db-wal
66*.db-shm
7−.fake
8−client/bin
9−client/obj
107.DS_Store
118.claude/
9+
10+# Rust core
11+/target
12+
13+# iOS shell (xcodegen project + boltffi/typegen output + build products are generated)
14+apple/SkrepkaApp.xcodeproj
15+apple/build
16+apple/generated
17+apple/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
AGENTS.mdnew file+119
@@ -0,0 +1,119 @@
1+# CLAUDE.md
2+
3+Decentralized, end-to-end encrypted messenger. No accounts, no home servers — identity is an Ed25519 keypair. Messages route over plain HTTP through any relay in an open federated mesh.
4+
5+`PROTOCOL.md` is the normative spec (v0.1 draft) and should be read before touching crypto, the wire format, or the server. The relay sees only the recipient pubkey and an opaque blob; the sender's identity and signature live *inside* the AEAD ciphertext, so a server never learns who sent a message.
6+
7+## Tech Stack
8+
9+- **Shared core:** Rust (edition 2021, `rust-version` 1.90) built on [Crux](https://redbadger.github.io/crux/) — `crux_core` (MVU), `crux_http`, `crux_kv`, `crux_time`. All business logic lives here: identity, auth, the long-poll loop, the outbox, contacts, profiles, message ingest.
10+- **FFI:** `boltffi` exports `CoreFFI` (`core/src/ffi.rs`) as a bincode bridge; `facet` drives Swift type generation from the core's `Event` / `ViewModel` / `Effect` types.
11+- **Apple shell:** Swift + SwiftUI, **iOS 17+ only** (`include_macos = false` in `core/boltffi.toml`). The shell is a thin effect executor — it performs HTTP, key-value, and timer effects and renders the `ViewModel`. It contains no business logic.
12+- **Crypto:** `ed25519-dalek` + `x25519-dalek` + `curve25519-dalek`, `chacha20poly1305` (XChaCha20-Poly1305), `hkdf` + `sha2` (HKDF-SHA256), `zstd` (plaintext compression). **No libsodium** — `core/src/crypto.rs` hand-reimplements libsodium's `crypto_sign_ed25519_{pk,sk}_to_curve25519` so blobs stay wire-compatible with any spec-following client.
13+- **Server:** `server.knot`, written in [Knot](https://github.com/ilyakooo0/knot) — a custom functional language compiled to a native binary. Its runtime transparently persists every `*ref` to SQLite, so all server state (`*sessions`, `*messages`, `*presence`, …) survives restarts despite reading like in-memory lists.
14+- **Persistence (client):** `crux_kv` — JSON blobs under the keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, `seen_ids`, and `messages:<peer_hex>`. The Swift shell backs this with one file per key under Application Support. The 64-byte Ed25519 secret key lives in the iOS Keychain, never in kv.
15+
16+## Project Layout
17+
18+```
19+core/ Rust shared core (crate `skrepka_core`)
20+ src/app.rs Crux App: Event / Model / ViewModel / Effect + update(). The whole state machine.
21+ src/model.rs Persisted state types (Settings, OwnProfile, Contact, StoredMessage, OutboxItem)
22+ and the flat ViewModel the shell renders.
23+ src/crypto.rs Identity, encrypt/decrypt, ed25519→x25519 derivation, auth-challenge signing.
24+ src/protocol.rs Plaintext payload (de)serialization (text / delivery.ack / profile) + Envelope.
25+ src/phonemic.rs Urbit-style @p syllable encoding of pubkeys; try_parse_pubkey accepts @p or hex.
26+ src/ffi.rs boltffi surface: update / resolve / view over bincode bytes.
27+ src/bin/codegen.rs Emits Swift bindings via facet typegen (feature `codegen`).
28+ boltffi.toml Apple packaging config (iOS 17 target, module `SkrepkaShared`).
29+
30+apple/
31+ project.yml xcodegen spec → SkrepkaApp.xcodeproj (bundle lol.skrepka.SkrepkaApp-iOS).
32+ SkrepkaApp/SkrepkaApp.swift @main entry point.
33+ SkrepkaApp/Core.swift Drives the core: serializes events in, dispatches effects out, resolves them back.
34+ SkrepkaApp/Effects.swift Effect handlers — Http (URLSession, 70s timeout), Time, KvStore (file per key),
35+ Keychain (loads or generates the 64-byte identity key).
36+ SkrepkaApp/Views.swift RootView, ConversationsView, ChatView, MessageBubble, Avatar.
37+ SkrepkaApp/Forms.swift AddContactView, SettingsView, EditProfileView.
38+ SkrepkaApp/QRView.swift QR generation + AVFoundation camera scanner.
39+ SkrepkaApp/PhotoPicker.swift PHPicker → downscaled 256px JPEG as base64.
40+ generated/ gitignored — Shared/ (xcframework + SPM package) and App/Skrepka (Swift types).
41+
42+server.knot The entire relay: config, types, routes, auth, poll, send, federation, background loops.
43+servers.json Public relay list (flat JSON array of URLs). A static suggestion, not a directory.
44+install.sh Linux deploy: downloads the release binary, writes a systemd unit, configures Caddy + TLS.
45+skrepka.service Reference systemd unit (install.sh writes its own, with --http-max-body-bytes=42M).
46+Justfile All build/run recipes.
47+.cargo/config.toml Pins IPHONEOS/MACOSX deployment targets (see Gotchas).
48+.github/workflows/build.yml CI: tests + clippy on the core, builds the iOS shell, compiles server.knot on
49+ linux-x86_64 / linux-arm64 / macos-arm64, cuts a release.
50+PROTOCOL.md Wire protocol specification.
51+PROTOCOL-REVIEW.md Audit of spec-vs-implementation gaps, cross-checked against the Rust core.
52+```
53+
54+## Build & Run
55+
56+One-time setup:
57+
58+```sh
59+rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
60+cargo install boltffi_cli
61+brew install xcodegen
62+```
63+
64+Recipes (`just <name>`; `just` alone runs `test`):
65+
66+```sh
67+just test # cargo test -p skrepka_core (crypto, protocol, @p, state machine)
68+just typegen # regenerate Swift types into apple/generated/App
69+just pack # build the core xcframework + Swift package into apple/generated/Shared
70+just project # xcodegen generate → apple/SkrepkaApp.xcodeproj
71+just generate # typegen + pack + project — run this after any core type change
72+just build SIM_ID=<udid> # xcodebuild for a booted simulator
73+just run SIM_ID=<udid> # install + launch on that simulator
74+just server # wipe server.db* and run the local ./server binary
75+```
76+
77+`just server` expects a **prebuilt `./server`** binary; there is no local recipe that compiles it. To build it you need the Knot compiler (`knot build server.knot`), which CI downloads from `ilyakooo0/knot` releases — on Linux it links statically against musl. `server` and `*.db*` are gitignored.
78+
79+Deploying a relay:
80+
81+```sh
82+curl -fsSL https://raw.githubusercontent.com/ilyakooo0/skrepka/master/install.sh | sudo DOMAIN=relay.example.com sh
83+```
84+
85+Every top-level scalar constant in `server.knot` is overridable at startup as `--name=value` (e.g. `--serverHost=relay.example.com`, `--federationEnabled=False`, `--trustForwardedFor=False`); `--help` lists them all.
86+
87+## Architecture
88+
89+The core is a pure MVU state machine. `Skrepka::update(Event, &mut Model) -> Command<Effect, Event>` is the only place state changes; `Skrepka::view(&Model) -> ViewModel` is the only thing the shell reads. The shell never decides anything — it turns `Effect`s into platform calls and resolves the results back in.
90+
91+Two kinds of `Event` variants, and the distinction matters:
92+
93+- **Shell-facing** (`IdentityLoaded`, `SendText`, `OpenChat`, `AddContact`, …) — cross the FFI, so they appear in the generated Swift `Event` enum.
94+- **Internal** (`LoadedContacts`, `PollResult`, `SendResult`, …) — carry non-FFI-safe payloads (`crux_http::Result`, `KeyValueError`) and are marked `#[serde(skip)] #[facet(skip)] #[facet(opaque)]` so they never reach Swift. Adding a new internal event means adding those attributes too, or typegen breaks.
95+
96+The `ViewModel` is deliberately flat and stringly-typed (`page: String`, `conn_status: String`) so the Swift side switches on plain strings rather than needing generated enums for UI state.
97+
98+Runtime flow: `IdentityLoaded` (from the Keychain, at boot) fans out six kv loads → `LoadedSettings` triggers `Connect` → `Authenticate` (challenge/verify, signature bound to the server's bare hostname under the `skrepka-auth-v1:` tag) → on a token, `Poll` (server long-polls ~25 s, client re-polls immediately) and `StartFlush` (drains the outbox one message at a time, encrypting at send time). Failures back off via `crux_time` — 3 s doubling to a 30 s cap.
99+
100+## Code Conventions
101+
102+- **Rust:** standard `rustfmt`; the code is written clippy-clean (note the targeted `#[allow]`s in `app.rs` / `ffi.rs`). Doc comments (`//!`, `///`) carry the design rationale and cite `PROTOCOL.md` sections — keep that habit; it's how the crypto stays auditable against the spec.
103+- **Swift:** SwiftUI, no business logic. `Core.swift` uses `try!` on bincode round-trips deliberately (a serialization mismatch is a build-time bug, not a runtime condition) with `swiftlint:disable` comments marking each.
104+- **Knot:** `server.knot` is written for clarity over performance — every collection is a flat list, every scan is O(n). The header comment says so explicitly. Its dense comments encode security reasoning (SSRF deny-list, rate-limit key choice, replay/dedup invariants); read them before changing behavior.
105+- Anything on the wire is **lowercase hex**; anything shown to a human can be **@p** (`ridler-binzod-…`). `try_parse_pubkey` accepts both.
106+
107+## Version Control
108+
109+Convention is **jj** (jujutsu), not git — but note the working copy is currently a **plain git checkout with no `.jj/`**. Run `jj git init --colocate` before using jj commands here.
110+
111+When reading diffs, pass `--git` to `jj diff` / `jj show` — the configured formatter is difftastic, whose ANSI output is meant for terminals, not models.
112+
113+## Gotchas
114+
115+- **Regenerate after core type changes.** Touching `Event`, `ViewModel`, or `Effect` without running `just generate` leaves the Swift side compiling against stale types.
116+- **`.cargo/config.toml` is load-bearing.** It pins `IPHONEOS_DEPLOYMENT_TARGET=17.0` / `MACOSX_DEPLOYMENT_TARGET=11.0` so `zstd-sys` (a C dep) and rustc agree on the minimum OS version. Without it the linker fails on a version mismatch.
117+- **Body-size cap on the relay.** `--http-max-body-bytes` must exceed `maxBlobLen` (40 MiB hex) plus the JSON envelope, or the runtime's default 16 MiB cap rejects a max-size blob with a bare `413` before the handler's `400 invalid_message` check runs. `install.sh` sets `42M`.
118+- **Refined types don't validate inside lists.** Knot's route-boundary checks only fire on top-level scalar body fields, so `handleSendMessages` and `handleRecvGossip` re-validate nested `to` / `pubkey` / `encryptedBlob` by hand. Preserve those checks.
119+- **Federation is unauthenticated by design.** `/federation/*` endpoints accept any caller; abuse is bounded by the SSRF deny-list, the `no_presence` gate, and per-IP rate limits — not by peer authentication. `PROTOCOL.md` §10 enumerates what that does and doesn't protect.
AUDIT-DEEP.mdnew file+230
@@ -0,0 +1,230 @@
1+# Skrepka Deep Audit — app.rs & model.rs (2026-07-16)
2+
3+**Scope:** Deep audit of `core/src/app.rs` and `core/src/model.rs`, cross-referenced against PROTOCOL.md §4, §6, §9, §10 and existing audits (AUDIT-NEW.md, AUDIT-FRESH.md, PROTOCOL-REVIEW.md). Findings below are **not** documented in any prior audit.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. State Machine — Poll Loop / Cursor / Budget
10+
11+### 🟡 D1. `events_stored` doesn't count successfully-decrypted-but-non-stored events — livelock on budget exhaustion
12+
13+**Type:** State machine bug (livelock)
14+**File:** `core/src/app.rs:2127, 2188-2200, 2248-2293, 2316, 2342-2349`
15+
16+The `events_stored` counter drives the cursor-restore decision on budget/count exhaustion (lines 2145-2147, 2163-2165): if `events_stored == 0`, the cursor is restored to `prev_cursor`, and the relay re-serves the same page on the next poll.
17+
18+`events_stored` is only incremented for:
19+- Text messages that **pass** dedup (line 2264 — inside `if !id_set.contains`)
20+- All `delivery.ack` payloads (line 2316)
21+- Profiles that **pass** staleness (line 2348 — inside `if ts >= c.last_profile_ts`)
22+
23+It is **NOT** incremented for:
24+- **Duplicate text messages** (line 2248: the `if !id_set.contains` block is not entered)
25+- **Stale profiles** (line 2342: the `if ts >=` block is not entered)
26+- **Profiles from strangers** (line 2327: `continue` before any increment)
27+- **Self-messages** (line 2188: `continue` before any increment)
28+- **Blocked-sender messages** (line 2198: `continue` before any increment)
29+
30+All of these are **successfully decrypted** — they consume the full AEAD decrypt cost and the per-blob byte budget — but they don't increment `events_stored`. If a poll page consists entirely of such events and the byte budget is exhausted mid-page, `events_stored == 0`, the cursor is restored, and the client re-polls with the old cursor. The relay re-serves the same page. **Livelock.**
31+
32+**Attack scenario:**
33+1. Client receives 4 large blobs (each ~20 MiB decoded, total ~80 MiB > 64 MiB budget).
34+2. First encounter: all 4 are new text messages. `events_stored = 3` (budget exhausted on 4th). Cursor kept at `page.cursor`. Relay deletes events. No issue.
35+3. **Hostile relay re-serves the same 4 blobs** (it kept copies despite cursor advance). All 4 are now duplicates. Budget exhausted on 4th blob. `events_stored == 0`. Cursor restored to `prev_cursor`.
36+4. Client re-polls with `prev_cursor`. Relay re-serves same 4 blobs. Go to 3. **Permanent livelock** — the client can never advance past this page, wasting bandwidth and battery on every cycle.
37+
38+The same pattern applies to a page of large stale profile blobs, large profile-from-stranger blobs, or large self-message echoes — all decrypt successfully, all are dropped, none increment `events_stored`.
39+
40+**Note:** AUDIT-FRESH.md N9 identified the budget-exhaustion livelock for the case where blobs **fail decryption** (events_stored == 0, hostile junk). The fix applied (keep cursor if `events_stored > 0`) addresses that case. But the **duplicate/stale/stranger/self/blocked** cases are different: the blobs ARE decryptable, the client DID process them (and doesn't need them again), but `events_stored` doesn't reflect this. This is a new variant of the N9 livelock that the existing fix does not cover.
41+
42+**Suggested fix:** Increment `events_stored` after successful decryption and parse, regardless of whether the event is stored:
43+```rust
44+let Ok(dec) = crate::crypto::decrypt(&id, &blob) else { continue; };
45+let Some(parsed) = protocol::parse_payload(&dec.plaintext) else { continue; };
46+events_stored += 1; // successfully decrypted — cursor can advance past this
47+```
48+This ensures the cursor is kept whenever at least one event was successfully processed (even if it was a duplicate/stale/stranger), breaking the livelock. The unprocessed tail (budget exhaustion) is still lost, but that's the accepted N9 tradeoff.
49+
50+---
51+
52+### 🔵 D2. `SetServerUrl` resets `send_retries` but not `poll_retries` — new relay inherits stale poll backoff
53+
54+**Type:** State machine bug (stale state on relay switch)
55+**File:** `core/src/app.rs:960`
56+
57+When switching relays, `SetServerUrl` resets `send_retries = 0` (line 960) and `flush_paused_until = 0` (line 959), with the comment: "The old relay's rate limit was the old relay's opinion. A new one is entitled to take our sends immediately."
58+
59+The same logic applies to `poll_retries` — it's the old relay's poll failure count — but it is **not reset**. If the old relay was failing (e.g., 5xx storm), `poll_retries` could be at 4 (backoff = 30s). The first poll to the new relay fails (e.g., transient network issue), and `backoff_poll` uses `backoff_ms(5) = 30s` instead of starting at 3s. The new relay's poll loop is penalized for the old relay's failures.
60+
61+**Attack scenario:** No attack needed — this triggers on any relay switch after a period of poll failures. The user switches to a healthy relay but experiences unnecessarily long poll backoffs.
62+
63+**Suggested fix:** Add `model.poll_retries = 0;` alongside `model.send_retries = 0;` at line 960.
64+
65+---
66+
67+## 2. State Machine — Outbox / Flush
68+
69+### 🔵 D3. `SendResult` pops outbox items even when `kv_load_failed` is set — duplicate sends on relaunch
70+
71+**Type:** State machine bug (write guard gap)
72+**File:** `core/src/app.rs:1547-1560, 1728-1733`
73+
74+If `kv_load_failed` is set (by a `LoadedMessages` failure) while a send is in flight, `SendResult` still pops the batch from `model.outbox` (line 1557-1559) on a 200 or 400. The subsequent `persist_outbox` (line 1560) calls `refuse_write`, which checks `kv_load_failed` and refuses the write. The items are removed from the in-memory outbox but **not from the on-disk outbox**.
75+
76+On relaunch, the outbox is re-loaded from disk — the popped items are still there. They are re-sent:
77+- For a **200** (relay accepted): duplicate sends. The recipient's dedup handles it, but the sender wastes bandwidth and the recipient sees duplicate delivery attempts.
78+- For a **400** (relay rejected): the items are re-sent and re-rejected, burning retry budget.
79+
80+**Timeline:**
81+1. Startup loads complete. `Connect` → `Authenticate` → `VerifyResult` → `Poll` + `StartFlush`.
82+2. `StartFlush` sends a batch. `flushing = true`, `in_flight` is set.
83+3. `LoadedMessages` for some peer fails. `kv_load_failed = true`.
84+4. `SendResult` arrives (200). `flushing` cleared, batch popped (line 1557-1559). `persist_outbox` refused (line 1729-1730).
85+5. On relaunch: outbox loaded from disk. Items re-sent.
86+
87+**Note:** The window is narrow — `LoadedMessages` is a lazy load triggered by `LoadedContacts`, and `StartFlush` fires from `VerifyResult` which also fires after startup loads. But the ordering is: startup loads → `Connect` → `Authenticate` → `VerifyResult` → `StartFlush` + `Poll` → `LoadedMessages` (lazy). So the send can be in flight when `LoadedMessages` fails.
88+
89+**Suggested fix:** Check `kv_load_failed` before popping in `SendResult`. If set, don't pop — leave items in the outbox for relaunch to handle:
90+```rust
91+if ((200..300).contains(&status) || status == 400) && !model.kv_load_failed {
92+ // pop and persist
93+}
94+```
95+Alternatively, don't pop if `persist_outbox` was refused (check the return of `refuse_write`).
96+
97+---
98+
99+## 3. Model / Persistence
100+
101+### 🔵 D4. `now_ms()` returns 0 on system clock failure — outbox TTL disabled
102+
103+**Type:** Robustness gap
104+**File:** `core/src/app.rs:580-585`
105+
106+```rust
107+fn now_ms() -> i64 {
108+ SystemTime::now()
109+ .duration_since(UNIX_EPOCH)
110+ .map(|d| d.as_millis() as i64)
111+ .unwrap_or(0)
112+}
113+```
114+
115+If the system clock is before `UNIX_EPOCH` (misconfigured device, RTK bug, NTP step), `now_ms()` returns 0. Consequences:
116+
117+- **Outbox TTL disabled:** `first_attempt` is stamped as 0 (line 1990-1993). `OutboxItem::is_expired` checks `first_attempt > 0` (line 170) — 0 means "never attempted," so the TTL never applies. Stuck items are retried forever (only the retry counter bounds them, which at 10 retries with slow backoff could take days).
118+- **`flush_paused_until` bypassed:** `now_ms() < flush_paused_until` → `0 < 0` → false. Flushing proceeds immediately, ignoring a relay's `Retry-After`.
119+- **`last_ack_ts` / `last_profile_ts` clamped to 0:** `ts.min(now)` → `ts.min(0)` → 0 (if ts is positive). All future acks/profiles pass the staleness check (any ts >= 0). Dedup is weakened.
120+
121+**Attack scenario:** No attack — this is a device misconfiguration. But on iOS, a clock that briefly reads as pre-epoch during boot could trigger this transiently.
122+
123+**Suggested fix:** Use `UNIX_EPOCH` as the floor instead of 0:
124+```rust
125+fn now_ms() -> i64 {
126+ SystemTime::now()
127+ .duration_since(UNIX_EPOCH)
128+ .map(|d| d.as_millis() as i64)
129+ .unwrap_or(1) // 1, not 0 — 0 is a valid "never attempted" sentinel
130+}
131+```
132+Or better: propagate the error and have callers handle clock failure explicitly.
133+
134+---
135+
136+### 🔵 D5. `LoadedMessages` merge doesn't update `seen_ids` — weakened dedup after `seen_ids` load failure
137+
138+**Type:** Dedup gap
139+**File:** `core/src/app.rs:815-856`
140+
141+The `LoadedMessages` handler merges on-disk messages into the in-memory conversation (union by ID, re-sort, trim). But it does **not** add the on-disk message IDs to `model.seen_ids`.
142+
143+When `seen_ids` loads successfully from kv at startup, this is fine — the on-disk IDs are already in the set. But when `seen_ids` **fails to load** (line 807-811, non-fatal), the set is empty. The `ingest_poll` seeding at line 2244-2246 seeds from the conversation:
144+```rust
145+if id_set.is_empty() {
146+ id_set.extend(convo.iter().map(|m| m.id.clone()));
147+}
148+```
149+
150+If a poll arrives **before** `LoadedMessages` for a peer, the conversation is empty (not loaded yet). The seeding adds no IDs. The poll's messages are stored and their IDs are added to the set. Later, `LoadedMessages` arrives and merges the on-disk messages — but their IDs are **never** added to `seen_ids` (the merge doesn't touch `seen_ids`, and the seeding only runs when `id_set.is_empty()`, which is no longer true after the poll).
151+
152+**Result:** On-disk message IDs are missing from `seen_ids`. Future replays of those on-disk messages pass the dedup check (line 2248) and are re-inserted into the conversation. The `LoadedMessages` merge filters duplicates by ID (line 830), so the conversation doesn't grow unboundedly — but each replay is processed (decrypted, inserted, acked) before the merge catches it.
153+
154+**Attack scenario:**
155+1. `seen_ids` kv load fails (device locked, file corruption).
156+2. `LoadedContacts` triggers `LoadedMessages` for peer P.
157+3. A poll arrives before `LoadedMessages` for P. P's conversation is empty. `seen_ids[P]` is seeded as empty.
158+4. Poll stores new messages, adds their IDs to `seen_ids[P]`.
159+5. `LoadedMessages` for P arrives. On-disk messages are merged into the conversation. Their IDs are NOT added to `seen_ids[P]`.
160+6. A replay of an on-disk message arrives. Its ID is not in `seen_ids[P]`. It passes dedup. It's inserted and acked.
161+
162+**Impact:** Weakened dedup — replays of on-disk messages are re-processed (decrypted, stored, acked). No permanent data corruption (the merge prevents duplicate conversation entries), but the recipient sends spurious delivery acks for old messages, and the sender receives false "delivered" confirmations.
163+
164+**Suggested fix:** In the `LoadedMessages` merge, add the on-disk message IDs to `seen_ids`:
165+```rust
166+if let Some(id_set) = model.seen_ids.get_mut(&peer) {
167+ id_set.extend(list.iter().map(|m| m.id.clone()));
168+}
169+```
170+
171+---
172+
173+## 4. Protocol Deviations
174+
175+### ⚪ D6. `ingest_poll` doesn't send profile to stranger on first contact — protocol "SHOULD" not followed
176+
177+**Type:** Protocol deviation (UX)
178+**File:** `core/src/app.rs:2221-2230, 2400-2412`
179+
180+PROTOCOL.md §4: "Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name, bio, or photo."
181+
182+When a stranger sends us a text, `ingest_poll` auto-creates a contact (line 2225-2229) and queues a `delivery.ack` (line 2400-2412). But it does **not** queue a `profile` broadcast back to the stranger. The stranger's client has our pubkey (they sent us a message) but no profile — they see our truncated @p but not our display name or avatar.
183+
184+`send_text` handles this correctly: on first contact (line 1858-1873), it queues a profile broadcast after the text. But `ingest_poll` doesn't.
185+
186+**Impact:** Minor UX issue — a stranger who initiates conversation doesn't see our profile until we manually send them a text or update our profile. Not a security vulnerability.
187+
188+**Suggested fix:** After auto-creating a contact in `ingest_poll`, queue a profile broadcast:
189+```rust
190+if !known {
191+ // ... auto-create contact ...
192+ let profile = Payload::Profile { /* ... */ };
193+ model.outbox.push_back(OutboxItem::profile(
194+ sender.clone(),
195+ Arc::new(protocol::serialize_payload(&profile, ts)),
196+ ));
197+ outbox_dirty = true;
198+}
199+```
200+
201+---
202+
203+## Summary of New Findings
204+
205+| # | Severity | Type | Title |
206+|---|----------|------|-------|
207+| D1 | 🟡 medium | Livelock | `events_stored` doesn't count decrypted-but-non-stored events — livelock on budget exhaustion |
208+| D2 | 🔵 low | Stale state | `SetServerUrl` doesn't reset `poll_retries` — new relay inherits old poll backoff |
209+| D3 | 🔵 low | Write guard gap | `SendResult` pops items when `kv_load_failed` — duplicate sends on relaunch |
210+| D4 | 🔵 low | Robustness | `now_ms()` returns 0 on clock failure — outbox TTL disabled |
211+| D5 | 🔵 low | Dedup gap | `LoadedMessages` merge doesn't update `seen_ids` — weakened dedup after load failure |
212+| D6 | ⚪ info | Protocol deviation | `ingest_poll` doesn't send profile to stranger on first contact |
213+
214+**Medium (1):** D1
215+**Low (4):** D2, D3, D4, D5
216+**Info (1):** D6
217+
218+---
219+
220+## Priorities
221+
222+1. **D1 (medium):** Fix `events_stored` to count all successfully decrypted events. This closes a livelock variant that the N9 fix doesn't cover — a hostile relay can permanently wedge the client by re-serving large duplicate/stale/stranger blobs.
223+
224+2. **D2 (low):** Add `model.poll_retries = 0;` to `SetServerUrl`. One-line fix, prevents stale backoff on relay switch.
225+
226+3. **D5 (low):** Update `seen_ids` in the `LoadedMessages` merge. Closes a dedup gap when `seen_ids` kv load fails.
227+
228+4. **D3 (low):** Guard `SendResult` pop with `kv_load_failed` check. Prevents duplicate sends on relaunch.
229+
230+5. **D4 (low):** Change `unwrap_or(0)` to `unwrap_or(1)` in `now_ms()`. Prevents TTL disablement on clock failure.
AUDIT-FFI-PHONEMIC-APPLE.mdnew file+470
@@ -0,0 +1,470 @@
1+# Skrepka Security Audit — FFI, Phonemic, Apple Shell (2026-07-16)
2+
3+**Scope:** Deep audit of `core/src/ffi.rs`, `core/src/phonemic.rs`, and all Apple shell files (`apple/SkrepkaApp/*.swift`). Cross-referenced against `PROTOCOL.md`, `AGENTS.md`, `AUDIT-NEW.md`, and `AUDIT-FRESH.md`. Findings below are **not** in prior audits.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. FFI Boundary
10+
11+### 🟠 F1. `try!` in Core.swift crashes on empty bytes from `guard` — FFI panic safety is a lie
12+
13+**Type:** FFI boundary / panic safety
14+**Files:** `core/src/ffi.rs:42-44` (`guard`), `apple/SkrepkaApp/Core.swift:30,38,49,60,66,74,81`
15+
16+The `guard` function in `ffi.rs` catches panics with `catch_unwind` and returns an empty `Vec<u8>`. The module doc (lines 4-9) claims: *"the shell already tolerates an empty effect list (it just does nothing) and an empty view (it renders the previous one), so a bug degrades into a stalled UI rather than a corrupted process."*
17+
18+This is false. Every bincode deserialization in `Core.swift` uses `try!`:
19+
20+```swift
21+// Core.swift:30 (init)
22+self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
23+
24+// Core.swift:38 (update)
25+let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
26+
27+// Core.swift:49 (dispatch)
28+let requests = try! Requests.bincodeDeserialize(input: effects).value
29+
30+// Core.swift:60 (render)
31+self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
32+```
33+
34+If `core.view()` or `core.update()` returns empty bytes (from a caught panic), `try! .bincodeDeserialize(input: [])` throws, and `try!` produces a fatal error — **crashing the app**, not degrading gracefully. The `init` case is the worst: there is no "previous view" to render, so the very first `core.view()` call would crash the app on launch if the core panics.
35+
36+**Attack scenario:** Any input that causes a panic inside `App::update` or `Skrepka::view` — an unforeseen edge case, a dependency bug, or an integer overflow in debug mode — would crash the app instantly via `try!` rather than degrading to a stalled UI as the FFI docs claim. On iOS, this is a clean crash with no data corruption, but it's a denial-of-service: the user can't use the app at all.
37+
38+**Exploitable in practice:** Requires triggering a panic in the Rust core. The core code is careful with `unwrap_or` / `unwrap_or_default`, but panics from dependencies (e.g., `serde_json` on malformed input, `zstd` on a crafted frame) or array indexing bugs are possible. A hostile relay crafting specific poll responses could potentially trigger a panic in the decrypt path.
39+
40+**Suggested fix:** Replace `try!` with `try?` or a `do/catch` block that logs the error and skips the deserialization. For `init`, provide a default empty `ViewModel` if `core.view()` returns empty bytes. Alternatively, have `guard` return a sentinel (e.g., a single zero byte) that the Swift side can detect as "panic occurred" and handle gracefully.
41+
42+---
43+
44+### 🟡 F2. `AssertUnwindSafe` is unsound — Model is read after a panic
45+
46+**Type:** FFI boundary / memory safety
47+**Files:** `core/src/ffi.rs:39-44` (`guard` + `AssertUnwindSafe`)
48+
49+The `guard` function uses `AssertUnwindSafe(f)` to bypass the `UnwindSafe` requirement. The doc comment (lines 39-41) justifies this: *"AssertUnwindSafe is sound here because a poisoned Bridge is never read again for correctness: the worst case is a dropped effect batch, and the next event starts a fresh update."*
50+
51+This is incorrect. The `Bridge` holds the `Core`, which holds the `Model`. `App::update` takes `&mut Model` and mutates it in place. If a panic occurs **mid-update** (after some model mutations but before the function returns), the `Model` is left in a partially-mutated, inconsistent state. The `catch_unwind` catches the panic, returns empty bytes, but the `Model` stays corrupted. The **next** `update` call operates on this corrupted model.
52+
53+The comment's claim that "the next event starts a fresh update" is misleading — a fresh update on a corrupted model is not fresh. The model's invariants (e.g., `contacts` is consistent with `messages`, `cursor` is valid, `outbox` items are well-formed) may be violated.
54+
55+**Attack scenario:** A panic mid-update corrupts the model. The next `update` call sees the corrupted state and may:
56+- Write corrupted state to kv (persisting the corruption across launches)
57+- Crash on a subsequent update (if the corruption violates an invariant checked by `update`)
58+- Produce incorrect behavior (e.g., sending messages to the wrong recipient)
59+
60+**Exploitable in practice:** Requires triggering a panic mid-update, which is the same bar as F1. The corruption is silent — the user sees a stalled UI (if F1 doesn't crash first), but the next interaction operates on corrupted state.
61+
62+**Suggested fix:** After a caught panic, reset the `Model` to a known-good state (e.g., re-load from kv) or mark the `Bridge` as poisoned and refuse further operations. Alternatively, snapshot the `Model` before `update` and restore it on panic. This is expensive but correct. At minimum, document that a panic may corrupt the model and recommend the shell re-initialize the core after a caught panic.
63+
64+---
65+
66+### 🔵 F3. `resolve` accepts arbitrary `EffectId` — no validation against pending effects
67+
68+**Type:** FFI boundary / input validation
69+**Files:** `core/src/ffi.rs:70-78` (`resolve`)
70+
71+The `resolve` method accepts any `u32` as an effect ID and any `&[u8]` as the resolution data. The `Bridge::resolve` call would fail with `Err` if the effect ID is unknown, which `guard` converts to an empty Vec. But the empty Vec would crash the Swift side via `try!` (see F1).
72+
73+More importantly, if the Swift side has a bug that resolves the same effect ID twice (e.g., from a race between a timer callback and a `.clear` call), the second resolution would produce effects for a non-existent or already-resolved effect. The `Bridge::resolve` would return `Err`, which becomes empty bytes, which crashes via `try!`.
74+
75+**Attack scenario:** Not directly exploitable by an attacker — the effect IDs are generated by the core and passed to the shell. But a shell bug (e.g., a race condition in timer handling) could trigger a double-resolve, crashing the app.
76+
77+**Suggested fix:** The shell should track which effect IDs are pending and guard against double-resolve. The FFI should return a distinguishable error for "unknown effect ID" vs "serialization failure" so the shell can handle it gracefully.
78+
79+---
80+
81+## 2. Phonemic
82+
83+### 🔵 P1. `to_ob` / `from_ob` round-trip asymmetry for empty input
84+
85+**Type:** Correctness asymmetry
86+**Files:** `core/src/phonemic.rs:98-109` (`to_ob`), `core/src/phonemic.rs:113-132` (`from_ob`)
87+
88+`to_ob(&[])` returns `Some(String::new())` (empty string), but `from_ob("")` returns `None` (because `split('-').filter(|p| !p.is_empty()).collect()` yields an empty Vec, which triggers `parts.is_empty() => None`).
89+
90+This means `to_ob` produces a string that `from_ob` can't decode. The round-trip property is broken for empty input.
91+
92+**Impact:** No caller passes empty bytes to `to_ob` for pubkey purposes (keys are always 32 bytes). The asymmetry is documented in the test `odd_length_input_has_no_spelling` (line 210). Harmless in practice.
93+
94+**Suggested fix:** Either make `to_ob(&[])` return `None` (consistent with odd-length rejection) or make `from_ob("")` return `Some(vec![])` (consistent with the encoding). The former is safer — an empty encoding is meaningless.
95+
96+---
97+
98+### 🔵 P2. `try_parse_pubkey` hex path lowercases redundantly — no bug, but misleading
99+
100+**Type:** Code clarity
101+**Files:** `core/src/phonemic.rs:154`
102+
103+`hex::decode(trimmed.to_lowercase())` — the `hex` crate's `decode` accepts both uppercase and lowercase hex digits. The `to_lowercase()` is redundant for the hex path. It doesn't cause a bug (the output of `valid_pubkey_hex` is always lowercase via `hex::encode`), but it suggests the case matters when it doesn't.
104+
105+**Suggested fix:** Remove the `to_lowercase()` or add a comment explaining it's for consistency, not correctness.
106+
107+---
108+
109+### ⚪ P3. No @p/hex collision for 32-byte pubkeys — confirmed safe
110+
111+**Type:** Correctness verification
112+**Files:** `core/src/phonemic.rs:148-158` (`try_parse_pubkey`)
113+
114+A 64-char hex string has no hyphens, so `from_ob` sees one part of 64 chars, fails the `part.len() != 6` check, returns `None`. The hex path then decodes it. No collision.
115+
116+A valid @p for a 32-byte key is 16 syllables (111 chars with hyphens). `hex::decode` fails on hyphens. No collision.
117+
118+A 6-char hex string (e.g., "abcdef") that happens to be a valid @p syllable: `from_ob` decodes it to 2 bytes, `valid_pubkey_hex` rejects (not 32 bytes). `hex::decode` gives 3 bytes, also rejected. No collision.
119+
120+**Verdict:** Safe. The @p-first ordering in `try_parse_pubkey` is correct.
121+
122+---
123+
124+### ⚪ P4. `from_ob` multibyte handling is correct — confirmed safe
125+
126+**Type:** Correctness verification
127+**Files:** `core/src/phonemic.rs:113-132`
128+
129+`to_lowercase()` is applied before `split('-')`. For non-ASCII input, `to_lowercase()` may change byte lengths, but the `part.len() != 6 || !part.is_ascii()` check on each part catches any non-ASCII characters that survive lowercasing. The test `multibyte_input_is_rejected_not_panicked_on` (line 279) confirms this.
130+
131+The `part[0..3]` and `part[3..6]` slices are safe because `part.len() == 6` and `part.is_ascii()` (ASCII = 1 byte per char, so byte indices == char indices).
132+
133+**Verdict:** Safe. No panic on multibyte input.
134+
135+---
136+
137+## 3. Apple Shell — HTTP Handling
138+
139+### 🟡 A1. `Http.perform` doesn't validate URL scheme — `file://` requests would be honored
140+
141+**Type:** Defense-in-depth gap
142+**Files:** `apple/SkrepkaApp/Effects.swift:104-106`
143+
144+```swift
145+guard let url = URL(string: req.url) else {
146+ return .err(.url(req.url))
147+}
148+var request = URLRequest(url: url)
149+```
150+
151+The shell accepts any URL scheme. `URLSession.data(for:)` handles `file://` URLs by reading local files. The core's `normalize_server_url` only accepts `http`/`https`, but if a bug in the core (or a future change) produces a `file://` or `data:` URL, the shell would honor it without question.
152+
153+**Attack scenario:** Not directly exploitable — the server URL is user-configured and validated by `normalize_server_url`. But if a bug in URL construction (e.g., `format!("{}/poll", model.settings.server_url)` where `server_url` is somehow `file:///etc/passwd`) reaches the shell, a local file could be read and its contents returned to the core as an HTTP response body. The core would try to parse it as JSON, fail, and treat it as an empty page.
154+
155+**Exploitable in practice:** Requires a core bug that produces a non-http URL. `normalize_server_url` prevents this for the server URL, but there's no belt-and-suspenders check in the shell.
156+
157+**Suggested fix:** Add a scheme check in `Http.perform`:
158+```swift
159+guard url.scheme == "http" || url.scheme == "https" else {
160+ return .err(.url(req.url))
161+}
162+```
163+
164+---
165+
166+### 🟡 A2. `HttpSessionDelegate` cancels same-host redirects — may break legitimate HTTPS upgrades
167+
168+**Type:** Protocol conformance / usability
169+**Files:** `apple/SkrepkaApp/Effects.swift:45-53`
170+
171+The redirect delegate cancels **all** redirects, including same-host redirects. A relay that redirects from `http://relay:80` to `https://relay:443` (a legitimate TLS upgrade) would have its redirect cancelled. The client would see the 3xx response and treat it as a failure.
172+
173+The protocol says "plain HTTP, served over TLS in production" (§5), and `normalize_server_url` accepts both `http` and `https`. So a relay that serves HTTP on port 80 and redirects to HTTPS on port 443 would be broken by this client.
174+
175+**Attack scenario:** Not an attack — a usability issue. A relay operator who sets up an HTTP-to-HTTPS redirect would find that Skrepka clients can't connect.
176+
177+**Exploitable in practice:** Depends on relay configuration. The `install.sh` deploys Caddy with TLS, so production relays serve HTTPS directly (no redirect). But a relay misconfigured to redirect would be unreachable.
178+
179+**Suggested fix:** Allow same-host redirects (compare the redirect target's host against the original request's host). Only cancel cross-host redirects, which are the token-exfiltration vector. Or, since the protocol says no redirects, document that relays must not redirect.
180+
181+---
182+
183+### 🔵 A3. `UInt16(clamping:)` silently truncates status codes > 65535
184+
185+**Type:** Input validation
186+**Files:** `apple/SkrepkaApp/Effects.swift:120`
187+
188+```swift
189+status: UInt16(clamping: http?.statusCode ?? 0),
190+```
191+
192+HTTP status codes are 3-digit integers (100-599), so this is fine in practice. But `UInt16(clamping:)` would clamp any value > 65535 to 65535, which is a valid `UInt16` but not a valid HTTP status. The core checks `status >= 200 && status < 300` for success, so 65535 would be treated as failure. No security issue.
193+
194+**Verdict:** Safe. HTTP status codes are bounded by the protocol.
195+
196+---
197+
198+### ⚪ A4. URLSession configuration is correct — confirmed safe
199+
200+**Type:** Implementation review
201+**Files:** `apple/SkrepkaApp/Effects.swift:91-101`
202+
203+The session configuration is:
204+- `ephemeral` — no cookies, no cache, no persistent storage ✓
205+- `httpCookieAcceptPolicy = .never` ✓
206+- `httpShouldSetCookies = false` ✓
207+- `httpCookieStorage = nil` ✓
208+- `urlCache = nil` ✓
209+- `requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData` ✓
210+- `timeoutIntervalForRequest = 70` — matches the server's ~25s long-poll ✓
211+- `tlsMinimumSupportedProtocolVersion = .TLSv12` ✓
212+
213+No sensitive data in logs. The `error.localizedDescription` in the catch block returns user-friendly strings like "The network connection was lost" — no relay-specific info beyond what the user already configured.
214+
215+**Verdict:** Safe. Good defense-in-depth.
216+
217+---
218+
219+### ⚪ A5. Response size limiting is correct — confirmed safe
220+
221+**Type:** Implementation review
222+**Files:** `apple/SkrepkaApp/Effects.swift:31-79`
223+
224+The `HttpSessionDelegate` tracks per-task byte counts in a thread-safe `NSLock`-guarded dictionary. When a task's accumulated body exceeds `maxResponseBytes` (64 MiB), the task is cancelled. The `didCompleteWithError` callback cleans up the dictionary entry. Every task eventually completes (success, failure, or cancel), so no dictionary leak.
225+
226+The 64 MiB cap matches the core's `MAX_POLL_RESPONSE_BYTES` (also 64 MiB). The shell's cap fires before the core's (the shell cancels the task mid-stream, the core never sees the full body), which is the correct ordering — the shell's cap prevents OOM before the core's check runs.
227+
228+**Verdict:** Safe. Well-designed.
229+
230+---
231+
232+## 4. Apple Shell — Keychain & KV Persistence
233+
234+### ⚪ A6. Keychain storage is correct — confirmed safe
235+
236+**Type:** Implementation review
237+**Files:** `apple/SkrepkaApp/Effects.swift:320-415`
238+
239+- `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — key never rides backup ✓
240+- `SecItemUpdate` first, then `SecItemAdd` if not found — no delete-then-add window ✓
241+- `errSecItemNotFound` is the only status that triggers key generation ✓
242+- 64-byte key length is validated on load ✓
243+- `loadOrCreateIdentity` is called only on the main actor (singleton `Core.shared`), so no race ✓
244+
245+The `store` function's update-then-add sequence has a theoretical race (between `SecItemUpdate` returning `errSecItemNotFound` and `SecItemAdd`, another thread could add the item), but `bootIdentity` is only called on the `@MainActor` singleton, so there's no concurrency.
246+
247+**Verdict:** Safe. Good practice.
248+
249+---
250+
251+### ⚪ A7. KV persistence is correct — confirmed safe
252+
253+**Type:** Implementation review
254+**Files:** `apple/SkrepkaApp/Effects.swift:195-316`
255+
256+- Serial queue — all operations serialized ✓
257+- Atomic writes with `.completeFileProtectionUnlessOpen` ✓
258+- `isExcludedFromBackup = true` — plaintext history not in iCloud backup ✓
259+- Percent-encoding with `.alphanumerics` — injective key→filename mapping, no path traversal ✓
260+- Legacy migration runs before any read (same serial queue) ✓
261+- Missing file = empty key (`.none`), not an error — correct distinction from read failure ✓
262+
263+**Verdict:** Safe. Well-designed.
264+
265+---
266+
267+## 5. Apple Shell — Photo Picker & QR Scanning
268+
269+### 🔵 A8. `PhotoPicker` last-resort quality doesn't check size cap
270+
271+**Type:** Logic bug / UX
272+**Files:** `apple/SkrepkaApp/PhotoPicker.swift:80-81`
273+
274+```swift
275+// Last resort: the smallest quality that still produces something.
276+return image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
277+```
278+
279+The loop (lines 71-79) reduces quality until the base64 fits within 64 KiB (`maxBase64Len`). But the last-resort return on line 81 doesn't check if the result fits. If a 256px JPEG at q0.1 still exceeds 64 KiB (possible for high-frequency noise images), the core's `SaveProfile` rejects it with "photo too large" and the user has no recourse.
280+
281+**Attack scenario:** A user picks a photo that, even at 256px and q0.1, produces > 64 KiB of base64. The profile save fails with no way to proceed. Not a security issue — a UX dead end.
282+
283+**Exploitable in practice:** Very unlikely for real photos (256px JPEG at q0.1 is typically 5-15 KiB). But a synthetic image (e.g., pure noise) could exceed the cap.
284+
285+**Suggested fix:** If the last-resort still exceeds the cap, return `nil` (triggering `onCancel`) or downscale further (e.g., 128px). Or document that the user should pick a different photo.
286+
287+---
288+
289+### 🔵 A9. `scannedKeyPayload` is business logic in the shell — portability concern
290+
291+**Type:** Protocol conformance / architecture
292+**Files:** `apple/SkrepkaApp/QRView.swift:24-35`
293+
294+`scannedKeyPayload` parses QR code content: strips URL schemes (`skrepka://`, `https://`, `http://`), extracts the last path component, and trims whitespace. This is parsing logic that determines what input the core receives.
295+
296+`AGENTS.md` says: *"The shell never decides anything — it turns Effects into platform calls and resolves the results back in."* This function decides how to interpret a QR code, which is arguably a business decision.
297+
298+An Android client would need to reimplement this logic. If the parsing differs (e.g., one client strips `skrepka://` and another doesn't), the same QR code would produce different inputs to the core.
299+
300+**Attack scenario:** Not a security vulnerability — the core's `try_parse_pubkey` validates the result regardless. But a portability bug: a QR code with a `skrepka://` prefix would work on iOS (stripped by `scannedKeyPayload`) but fail on an Android client that doesn't strip the scheme.
301+
302+**Suggested fix:** Move `scannedKeyPayload` into the core (e.g., `try_parse_pubkey_from_qr` or add scheme-stripping to `try_parse_pubkey`). The shell should pass the raw QR content to the core.
303+
304+---
305+
306+### 🔵 A10. `QRScannerView` doesn't check camera permission
307+
308+**Type:** UX gap
309+**Files:** `apple/SkrepkaApp/QRView.swift:75-80`
310+
311+`ScannerVC.viewDidLoad` calls `AVCaptureDevice.default(for: .video)`, which returns `nil` if camera permission hasn't been granted (or has been denied). The `guard` fails silently, and the scanner shows a black screen with no error message or permission prompt.
312+
313+**Attack scenario:** Not a security issue. A user who denied camera permission can't scan QR codes and sees a black screen with no explanation.
314+
315+**Suggested fix:** Check `AVCaptureDevice.authorizationStatus(for: .video)` and request permission if not determined. Show an error message if denied.
316+
317+---
318+
319+### ⚪ A11. `QRScannerView` camera lifecycle is correct — confirmed safe
320+
321+**Type:** Implementation review
322+**Files:** `apple/SkrepkaApp/QRView.swift:101-114`
323+
324+`startRunning()` and `stopRunning()` are dispatched to a background queue (`DispatchQueue.global(qos: .userInitiated)`) to avoid blocking the main thread. The `done` flag in `Coordinator` prevents multiple `onScan` callbacks. The camera session is properly stopped in `viewWillDisappear`.
325+
326+**Verdict:** Safe.
327+
328+---
329+
330+## 6. Apple Shell — Logic Leakage & Protocol Conformance
331+
332+### 🟡 A12. `BackgroundRefresh` polls `core.view` in a tight loop — main actor contention
333+
334+**Type:** Performance / architecture
335+**Files:** `apple/SkrepkaApp/SkrepkaApp.swift:156-163`
336+
337+The `settle` function polls `core.view` every 200ms, reading the `@Published` `ViewModel` on the main actor. While the overhead per poll is minimal (reading a published property), this runs for up to 25 seconds during a background refresh. If the core is processing a large poll response (which also runs on the main actor via `resolve`), the 200ms polls interleave with core processing.
338+
339+More importantly, `core.view` is a `@Published` property backed by `ViewModel`, which is a value type. Each read copies the entire `ViewModel` (including `contacts: Vec<ContactVM>` and `messages: Vec<MessageVM>`). For a user with 500 contacts and 1000 messages, each poll copies ~1 MB of data. Over 25 seconds at 200ms intervals, that's 125 copies = ~125 MB of transient allocations.
340+
341+**Attack scenario:** Not an attack — a performance issue. A user with many contacts and active conversations would experience increased memory pressure during background refresh.
342+
343+**Suggested fix:** Use a Combine subscription (`core.$view.sink`) instead of polling, or have the core expose a lightweight "status changed" flag that the shell can check without copying the full `ViewModel`.
344+
345+---
346+
347+### 🔵 A13. `Core.swift` `dispatch` silently drops effects if `try!` crashes — no recovery
348+
349+**Type:** Error handling
350+**Files:** `apple/SkrepkaApp/Core.swift:47-53`
351+
352+```swift
353+private func dispatch(_ effects: [UInt8]) {
354+ // swiftlint:disable:next force_try
355+ let requests = try! Requests.bincodeDeserialize(input: effects).value
356+ for request in requests {
357+ process(request)
358+ }
359+}
360+```
361+
362+If `effects` is valid bincode but contains an effect variant the Swift side doesn't know about (e.g., after a core update that adds a new effect without regenerating Swift types), `try!` would crash. The AGENTS.md warns about this: *"Touching Event, ViewModel, or Effect without running just generate leaves the Swift side compiling against stale types."*
363+
364+But there's no runtime recovery. The crash is immediate and unrecoverable. A safer approach would be to catch the deserialization error, log it, and skip the unknown effect — the core would see the effect as unresolved and its watchdog would handle it.
365+
366+**Suggested fix:** Replace `try!` with `try?` and log the error. Unknown effects are dropped; the core's watchdog handles the missing resolution.
367+
368+---
369+
370+### ⚪ A14. `Time.handle` timer lifecycle is correct — confirmed safe
371+
372+**Type:** Implementation review
373+**Files:** `apple/SkrepkaApp/Effects.swift:147-191`
374+
375+The `timers` dictionary is on the `@MainActor`. The `schedule` function removes the old work item before inserting a new one. The `DispatchWorkItem`'s callback runs on the main queue (via `MainActor.assumeIsolated`), so there's no interleaving between `schedule` and the callback. The `.clear` case removes and cancels the work item.
376+
377+No race condition: all operations are on the main actor / main queue, which is single-threaded.
378+
379+**Verdict:** Safe.
380+
381+---
382+
383+### ⚪ A15. `SkrepkaApp.swift` background refresh is correct — confirmed safe
384+
385+**Type:** Implementation review
386+**Files:** `apple/SkrepkaApp/SkrepkaApp.swift:64-164`
387+
388+- `isProtectedDataAvailable` guard prevents running while locked ✓
389+- `settle` polling respects `Task.isCancelled` and deadline ✓
390+- `setTaskCompleted(success: !Task.isCancelled)` correctly reports failure on cancellation ✓
391+- `schedule()` is called before `run()` — next refresh is always queued ✓
392+- `Core.shared` is only accessed after the `isProtectedDataAvailable` guard ✓
393+
394+**Verdict:** Safe. Well-designed.
395+
396+---
397+
398+## 7. Protocol & AGENTS.md Conformance
399+
400+### 🔵 A16. AGENTS.md says "five kv loads" but core does six — `seen_ids` added
401+
402+**Type:** Documentation drift
403+**Files:** `AGENTS.md:98` ("fans out five kv loads"), `core/src/app.rs:719-726` (six loads)
404+
405+AGENTS.md line 98 says: *"IdentityLoaded fans out five kv loads → LoadedSettings triggers Connect"*. But the core actually fans out **six** kv loads: `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `seen_ids` (line 725: `KeyValue::get(K_SEEN_IDS)`). The `STARTUP_LOADS` constant is 6 (line 312).
406+
407+**Impact:** Documentation drift, not a bug. A developer reading AGENTS.md would expect five loads and be confused by the sixth.
408+
409+**Suggested fix:** Update AGENTS.md to say "six kv loads" and include `seen_ids` in the list.
410+
411+---
412+
413+### 🔵 A17. AGENTS.md lists kv keys without `seen_ids` — stale documentation
414+
415+**Type:** Documentation drift
416+**Files:** `AGENTS.md:14` ("keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `messages:<peer_hex>`")
417+
418+The `seen_ids` key is not listed in AGENTS.md's kv key list. It was added as part of the dedup fix (AUDIT-NEW.md Q18) but the documentation wasn't updated.
419+
420+**Suggested fix:** Add `seen_ids` to the kv key list in AGENTS.md.
421+
422+---
423+
424+## Summary of New Findings
425+
426+| # | Severity | Type | Title |
427+|---|----------|------|-------|
428+| F1 | 🟠 high | FFI/panic | `try!` in Core.swift crashes on empty bytes from `guard` — FFI panic safety claim is false |
429+| F2 | 🟡 medium | FFI/memory | `AssertUnwindSafe` is unsound — Model is read after a panic, corruption persists |
430+| F3 | 🔵 low | FFI/input | `resolve` accepts arbitrary EffectId — double-resolve crashes via `try!` |
431+| P1 | 🔵 low | Correctness | `to_ob`/`from_ob` round-trip asymmetry for empty input |
432+| P2 | 🔵 low | Clarity | `try_parse_pubkey` hex path lowercases redundantly |
433+| P3 | ⚪ info | Safe | No @p/hex collision for 32-byte pubkeys — confirmed |
434+| P4 | ⚪ info | Safe | `from_ob` multibyte handling is correct — confirmed |
435+| A1 | 🟡 medium | HTTP/defense | `Http.perform` doesn't validate URL scheme — `file://` would be honored |
436+| A2 | 🟡 medium | HTTP/protocol | Redirect delegate cancels same-host redirects — breaks HTTPS upgrades |
437+| A3 | 🔵 low | HTTP/input | `UInt16(clamping:)` silently truncates status codes > 65535 |
438+| A4 | ⚪ info | Safe | URLSession configuration is correct |
439+| A5 | ⚪ info | Safe | Response size limiting is correct |
440+| A6 | ⚪ info | Safe | Keychain storage is correct |
441+| A7 | ⚪ info | Safe | KV persistence is correct |
442+| A8 | 🔵 low | Photo/UX | `PhotoPicker` last-resort quality doesn't check size cap |
443+| A9 | 🔵 low | Architecture | `scannedKeyPayload` is business logic in the shell — portability concern |
444+| A10 | 🔵 low | QR/UX | `QRScannerView` doesn't check camera permission |
445+| A11 | ⚪ info | Safe | QR scanner camera lifecycle is correct |
446+| A12 | 🟡 medium | Performance | `BackgroundRefresh` polls `core.view` — copies full ViewModel every 200ms |
447+| A13 | 🔵 low | Error handling | `Core.swift` `dispatch` crashes on unknown effect — no recovery |
448+| A14 | ⚪ info | Safe | Timer lifecycle is correct |
449+| A15 | ⚪ info | Safe | Background refresh is correct |
450+| A16 | 🔵 low | Docs | AGENTS.md says "five kv loads" but core does six |
451+| A17 | 🔵 low | Docs | AGENTS.md kv key list missing `seen_ids` |
452+
453+**High (1):** F1
454+**Medium (4):** F2, A1, A2, A12
455+**Low (8):** F3, P1, P2, A3, A8, A9, A10, A13, A16, A17
456+**Info (8):** P3, P4, A4, A5, A6, A7, A11, A14, A15
457+
458+---
459+
460+## Priorities
461+
462+1. **F1 (high):** Replace `try!` in Core.swift with graceful error handling. The FFI's panic safety design is undermined by `try!` — a caught panic crashes the app instead of degrading. This is the most impactful finding because it turns every potential core panic into an immediate app crash.
463+
464+2. **F2 (medium):** Address `AssertUnwindSafe` soundness. After a caught panic, the Model may be corrupted. At minimum, document the risk and consider resetting the Model from kv on panic recovery.
465+
466+3. **A1 (medium):** Add URL scheme validation in `Http.perform`. Defense-in-depth against a core bug producing non-http URLs.
467+
468+4. **A2 (medium):** Allow same-host redirects or document that relays must not redirect. The current all-redirects-cancelled policy breaks legitimate HTTPS upgrades.
469+
470+5. **A12 (medium):** Replace `settle`'s polling with a Combine subscription to avoid copying the full ViewModel every 200ms during background refresh.
AUDIT-FRESH.mdnew file+385
@@ -0,0 +1,385 @@
1+# Skrepka Security Audit — Fresh Pass (2026-07-15)
2+
3+**Scope:** Exhaustive multi-pass audit of PROTOCOL.md, server.knot, core/src/ (app.rs,
4+crypto.rs, protocol.rs, model.rs, phonemic.rs, ffi.rs), apple/SkrepkaApp/ (Effects.swift,
5+Core.swift, Views.swift, Forms.swift), install.sh. Findings below are **not** in
6+PROTOCOL-REVIEW.md or AUDIT-NEW.md, or are new angles on previously-noted areas.
7+
8+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
9+
10+---
11+
12+## 1. Cryptographic Protocol
13+
14+### 🔴 N1. No AAD on AEAD — envelope `to` field is unauthenticated, enabling silent targeted censorship by any relay
15+
16+**Type:** Design gap (protocol + crypto.rs)
17+**Files:** `core/src/crypto.rs:384-387` (encrypt), `core/src/crypto.rs:434-438` (decrypt)
18+
19+AUDIT-NEW.md Q2 notes this but downgrades the impact to "equivalent to message loss, which is
20+an accepted limitation." That understates it. The real issue is **silent, targeted message loss
21+with zero detectability**:
22+
23+A relay holding a victim's messages can swap the `to` field on a *specific* message (from a
24+sender the relay wants to censor), while leaving other messages intact. The recipient never
25+sees the message and gets no error. The `delivery.ack` system doesn't help because the recipient
26+never received the message to ack it, and the sender's UI shows the message as "sent"
27+indefinitely — which is the same behavior as "the recipient is offline" or "the recipient read
28+it but hasn't acked yet." There is **no way for either party to distinguish "the relay ate
29+Alice's message" from "Alice didn't send anything" or "Bob is just slow to ack."**
30+
31+This is worse than the generic "relay can drop messages" case. Dropping is *eventually*
32+detectable — the sender sees their message stuck at "sent" and can investigate. But `to`-swapping
33+moves the message to a *different recipient's* mailbox, where it's undecryptable and gets
34+silently dropped on poll. The original recipient's cursor is unaffected (no events), so the
35+next poll is normal. The censorship is invisible.
36+
37+**Attack scenario:**
38+1. Alice sends a message to Bob via relay R.
39+2. R swaps `to` from Bob's pubkey to Charlie's.
40+3. Message stored under Charlie's mailbox. Charlie can't decrypt (wrong key), drops silently.
41+4. Bob polls, gets nothing. Normal poll, no error.
42+5. Alice's UI shows "sent" — same as if Bob were merely offline.
43+6. No delivery.ack ever comes. Alice can't tell if Bob received it or not.
44+
45+The relay can do this selectively — censor messages from specific senders, or suppress specific
46+content patterns (inferred from blob size), while delivering everything else normally. The
47+censorship is undetectable because the sender sees the same "sent" status they'd see for any
48+offline recipient, and the recipient sees nothing unusual.
49+
50+**Suggested fix:** Include `version_byte || ephemeral_public || nonce || recipient_ed25519_public`
51+as AAD in the AEAD. The `to` field must match `recipient_ed25519_public` in the AAD. A relay
52+swapping `to` causes the wrong recipient to fail AEAD decryption (before signature check), and
53+the blob is dropped before cursor advancement. The *original* recipient's message stays in the
54+mailbox. Wire-format change (v0x02).
55+
56+### ⚪ N2. HKDF info string and wire version byte are independent constants
57+
58+**Type:** Code hygiene (crypto.rs)
59+**Files:** `core/src/crypto.rs:28` (`HKDF_INFO = b"skrepka-v1"`), `crypto.rs:33` (`WIRE_VERSION = 1`)
60+
61+The spec says "A future revision that changes the crypto primitives should bump both the version
62+byte and the HKDF `info` string together." But there's no code-level binding between them. A
63+developer bumping `WIRE_VERSION` to `2` could forget to update `HKDF_INFO`, causing two different
64+crypto versions to derive keys with the same KDF label.
65+
66+**Suggested fix:** Derive the info string from the version byte or add a compile-time assertion
67+linking them.
68+
69+---
70+
71+## 2. Server Implementation
72+
73+### 🟡 N3. `appendMessage` dedup scan is O(n) — identical-blob replay amplifies to O(n × batch_size)
74+
75+**Type:** Implementation concern / DoS amplification (server.knot)
76+**Files:** `server.knot:919-937`
77+
78+AUDIT-NEW.md Q7 notes the O(n) dedup scan. The amplification is worse than described for
79+identical-blob replay: a batch of 100 identical blobs to the same recipient does 100 × O(|messages|)
80+scans. With |messages| = 10,000, that's 1M comparisons per request. At 60 requests/min, that's
81+60M comparisons/min. Each comparison is a string equality on 64-char hex pubkeys + 346+ char
82+hex blobs.
83+
84+**Attack:** An attacker authenticates (free keypair) and sends batches of 100 identical
85+min-size blobs to one recipient. Each request is ~35 KB but triggers 1M comparisons. Bounded
86+by the 60/min rate limit, but the CPU amplification is ~1000x per request.
87+
88+**Suggested fix:** Index `*messages` by `(toKey, encryptedBlob)` or use a hash-based dedup set.
89+
90+### 🟡 N4. `capForwards` eviction is non-deterministic when retry counts are equal
91+
92+**Type:** Implementation bug (server.knot)
93+**Files:** `server.knot:515-522` (`capForwards`)
94+
95+AUDIT-NEW.md Q9 notes this. The sort by `retries` only (no secondary sort key) means when many
96+entries share `retries: 0`, the sort is unstable and any retries-0 entry can be dropped —
97+including a fresh forward for a healthy peer that would have succeeded on the first try.
98+
99+**Impact:** Delivery delay, not permanent loss (the message stays in `*messages` and can be
100+re-forwarded on the next presence announce). But it's a real availability issue under
101+federation abuse.
102+
103+**Suggested fix:** Sort by `(retries, encryptedBlob)` for determinism, or skip entries for
104+peers in backoff before capping.
105+
106+### 🟡 N5. `handleRecvGossip` `fromServer` not verified against connecting peer's address
107+
108+**Type:** Design gap (server.knot)
109+**Files:** `server.knot:1533-1578`
110+
111+AUDIT-NEW.md Q29 notes this. The `fromServer` field is self-asserted and used to route forwarded
112+messages. An attacker can claim any `fromServer` hostname. The server writes presence rows with
113+that hostname and forwards queued messages there. This is the root cause of the gossip-redirect
114+attack and is already documented, but the spec's §10 should state more prominently that **the
115+server has no mechanism to verify that the connecting peer is who it claims to be**.
116+
117+The `fromServer` field is a `ServerName` (format-validated), but format validation only checks
118+length, lowercase, and no trailing dot — it does not verify the peer controls that hostname.
119+
120+**Suggested fix:** Document more prominently. Real fix needs reverse-DNS verification or peer
121+authentication (signed gossip).
122+
123+### 🔵 N6. `genToken` generates one hex digit per `randomInt 16` call — entropy waste
124+
125+**Type:** Implementation concern (server.knot)
126+**Files:** `server.knot:532-537`
127+
128+AUDIT-NEW.md Q10 notes the CSPRNG dependency. Additionally, generating one hex digit at a time
129+is inefficient — each `randomInt 16` call should produce at least 4 bits, but if the runtime's
130+`randomInt` draws from a wider source (e.g., 32-bit `getrandom`), it wastes 28 bits per call.
131+48 calls × 28 wasted bits = 1344 bits wasted per token. Not a security issue (entropy is
132+abundant) but inefficient.
133+
134+**Verdict:** Info.
135+
136+---
137+
138+## 3. Client Core Logic
139+
140+### 🟡 N7. `seen_ids` dedup set is unbounded — grows without limit per peer
141+
142+**Type:** Implementation concern (app.rs)
143+**Files:** `core/src/app.rs:325` (`seen_ids: BTreeMap<String, HashSet<String>>`)
144+
145+The `seen_ids` map (Q18 fix) is persisted to kv and loaded at startup. It's a `BTreeMap<String,
146+HashSet<String>>` — one entry per peer, each a set of message IDs. The set is never trimmed.
147+Over time, a peer who sends thousands of messages accumulates thousands of IDs. The kv blob
148+(`seen_ids`) grows without bound.
149+
150+With `MAX_ID_LEN` = 128 chars and UUIDs = 36 chars, 1000 messages per peer (the conversation cap)
151+= 36 KB per peer. With 500 contacts (`MAX_CONTACTS`), that's 18 MB. The `seen_ids` kv blob is
152+rewritten in full on every conversation touch (line 2316), so a large set makes every message
153+receipt slower. On iOS, a 18 MB JSON blob written to Application Support on every incoming
154+message is a real performance issue.
155+
156+**Suggested fix:** Cap the `seen_ids` set per peer (e.g., keep the last 2000 IDs) and trim
157+periodically. Or use a time-based expiry. Or a Bloom filter with a fixed size.
158+
159+### 🟡 N8. `ingest_poll` advances cursor before processing events — crash between advance and persist loses messages
160+
161+**Type:** Implementation concern (app.rs)
162+**Files:** `core/src/app.rs:2024-2089`
163+
164+The cursor is advanced to `page.cursor` on line 2038 (before the event loop), and restored to
165+`prev_cursor` on budget exhaustion (line 2086) or event count overflow (line 2069). The cursor
166+is persisted to kv AFTER `ingest_poll` returns (line 1369), via a `KeyValue::set` effect. This
167+means the cursor write only happens after the event loop completes successfully.
168+
169+However, there's a crash window: if the app crashes between `model.cursor = clamped_cursor`
170+(line 2038) and the cursor persist effect (line 1369), the in-memory cursor is advanced but
171+the persisted cursor is still `prev_cursor`. On relaunch, the client sends `prev_cursor` to
172+the relay, which re-serves the events. No message loss — the persisted cursor is the safe one.
173+
174+**Verdict:** Safe. The cursor is only authoritative when persisted, and the persist happens
175+after `ingest_poll` completes. A crash during `ingest_poll` loses no data because the persisted
176+cursor hasn't changed. Info.
177+
178+### 🟡 N9. `ingest_poll` budget exhaustion drops remaining events — client is stuck if the relay always serves an oversized page
179+
180+**Type:** Implementation concern (app.rs)
181+**Files:** `core/src/app.rs:2054-2089`
182+
183+When the `MAX_POLL_TOTAL_BYTES` budget is exhausted mid-page, the loop breaks, the cursor is
184+restored to `prev_cursor`, and an error is set. The next poll sends `prev_cursor` again, and
185+the relay re-serves the same events. If the page always exceeds the budget (e.g., the relay
186+serves 50 large blobs and the budget only covers 10), the client is stuck: poll, process 10,
187+drop 40, re-poll, relay serves the same 50, process the same 10 (deduped, fast), drop 40, etc.
188+
189+The dedup makes re-processing fast (already-seen IDs are O(1) drops), but the client never
190+advances past the oversized page. The relay's events are served in `receivedAt` order, so the
191+same 50 events come back every time. The cursor never moves. The client is stuck in a loop.
192+
193+This is a **livelock**: the client polls, processes some events, but can never ack them. The
194+relay keeps serving the same page. The client's error message ("relay sent an oversized poll
195+page") is set but may not be visible in the UI.
196+
197+**Attack scenario:** A malicious relay serves a page of 50 blobs, each just under `MAX_BLOB_LEN`
198+(42 MiB). The total page is ~2 GiB, far exceeding the 64 MiB budget. The client processes a few,
199+drops the rest, and is stuck. Or more subtly: the relay serves 50 blobs that are each ~1.3 MiB
200+(total ~65 MiB, just over the 64 MiB budget). The client processes 49, drops 1, and is stuck
201+because the cursor never advances past the 50th event.
202+
203+**Suggested fix:** When the budget is exhausted, advance the cursor to the last *successfully
204+processed* event's `receivedAt` (not `page.cursor` and not `prev_cursor`). This acks the
205+processed events and lets the client move forward, dropping only the unprocessed tail. The
206+relay re-serves only the unprocessed events on the next poll. This changes the behavior from
207+"all-or-nothing cursor advance" to "partial ack."
208+
209+### 🟡 N10. Profile `ts` staleness check uses `>=` — equal-timestamp replay is a no-op, but the check is per-contact, not per-field
210+
211+**Type:** Implementation concern (app.rs)
212+**Files:** `core/src/app.rs:2174-2179` (approximate)
213+
214+AUDIT-NEW.md Q19 analyzes this and concludes it's safe because the attacker can't modify the
215+content without breaking the signature. I agree — the `>=` is correct and a same-`ts` replay
216+is a true no-op (same ciphertext, same signature, same content). Info.
217+
218+---
219+
220+## 4. Wire Format / Parsing
221+
222+### 🟡 N11. `protocol.rs` `parse_payload` does not validate `ts` is positive — negative `ts` clamped to 0 is harmless but surprising
223+
224+**Type:** Implementation concern (protocol.rs)
225+**Files:** `core/src/protocol.rs:100`
226+
227+AUDIT-NEW.md Q24 notes this. A `ts` of 0 or negative is clamped to `max(0, ...)` in `ingest_poll`.
228+A message with `ts = 0` is inserted at the earliest chronological position and trimmed first.
229+This is harmless. Info.
230+
231+### 🟡 N12. `delivery.ack` parsing accepts non-string entries in `ack_ids` — filtered silently, no length check on individual IDs after filtering
232+
233+**Type:** Implementation concern (protocol.rs)
234+**Files:** `core/src/protocol.rs:116-126`
235+
236+AUDIT-NEW.md Q26 notes this. The double-check (raw count + post-filter count) is correct.
237+Non-string entries are filtered. Individual ID length is checked (`ack_ids.iter().any(|id|
238+id.len() > MAX_ID_LEN)`). This is correct. Info.
239+
240+---
241+
242+## 5. Federation
243+
244+### 🟠 N13. Gossip amplification: 100 online events trigger 100 mailbox scans + forward staging — O(100 × |messages|) per request
245+
246+**Type:** Design gap (server.knot)
247+**Files:** `server.knot:1544-1578` (`handleRecvGossip`)
248+
249+AUDIT-NEW.md Q27 notes this. A single gossip request with 100 `online` events for 100 different
250+keys triggers `forwardQueuedToServer` for each key, which scans the entire mailbox. With
251+|messages| = 10,000, that's 100 × 10,000 = 1M comparisons per request. At 60/min per IP, that's
252+60M comparisons/min. An attacker with 10 IPs can do 600M comparisons/min.
253+
254+Additionally, each `forwardQueuedToServer` call stages forward entries, which consume
255+`maxForwardsPerServer` slots and trigger HTTP POSTs to the attacker's server. The attacker
256+receives copies of the victim's ciphertext.
257+
258+**Suggested fix:** Limit the number of newly-online keys per gossip batch that trigger forwards
259+(e.g., cap at 10). Or batch the mailbox scan across all newly-online keys in one pass.
260+
261+### 🟡 N14. `offline` gossip events don't cancel pending forwards — wasted retries for up to 80 minutes
262+
263+**Type:** Design gap (server.knot)
264+**Files:** `server.knot:1535-1541`
265+
266+AUDIT-NEW.md Q28 notes this. When an `offline` event is received, pending forwards to that server
267+are not cancelled. They retry for up to `retryBackoffMax × maxForwardRetries` = 8 min × 10 = 80
268+minutes, wasting `maxForwardsPerServer` slots and generating failed HTTP requests.
269+
270+If the offline event was spoofed, the forwards continue — and if the key is actually still
271+online at that server, the forward succeeds on retry. The impact is minimal.
272+
273+**Suggested fix:** When processing an `offline` event, optionally drop pending forwards to that
274+server for the offline key. But this could cause message loss if the offline event is spoofed.
275+Better to leave as-is and document.
276+
277+---
278+
279+## 6. Deployment / install.sh
280+
281+### 🟡 N15. `install.sh` downloads binary over HTTPS but checksum is best-effort — missing checksum silently proceeds
282+
283+**Type:** Deployment concern (install.sh)
284+**Files:** `install.sh:150-181`
285+
286+The checksum verification is best-effort: if the `.sha256` file doesn't exist (line 162 fails),
287+the script warns and proceeds. If `sha256sum`/`shasum` is not available, it warns and proceeds.
288+A checksum that exists but doesn't match is fatal (line 169-174) — correct.
289+
290+The concern: a compromised release asset without a published `.sha256` file (e.g., a pre-checksum
291+release, or a supply-chain attack that removes the checksum file) would be installed without
292+verification. The script trusts GitHub's HTTPS, but HTTPS authenticates the CDN, not the
293+artifact. A compromised GitHub release would sail through.
294+
295+**Suggested fix:** Make the checksum mandatory for all new installs. Keep the best-effort
296+behavior only for upgrades from pre-checksum releases, with a prominent warning.
297+
298+### ⚪ N16. `install.sh` DOMAIN validation rejects uppercase but the Caddyfile interpolation is safe
299+
300+**Type:** Deployment review (install.sh)
301+**Files:** `install.sh:34-45`
302+
303+DOMAIN is lowercased and stripped of scheme/path/port before interpolation into the Caddyfile
304+and systemd unit. The validation rejects anything outside `[a-z0-9.-]`. This prevents config
305+injection (a stray quote or semicolon in the Caddyfile). Good.
306+
307+**Verdict:** Safe. Info.
308+
309+---
310+
311+## 7. Apple Shell
312+
313+### ⚪ N17. URLSession redirect cancellation is correct — bearer token exfiltration via 302 is prevented
314+
315+**Type:** Implementation review (Effects.swift)
316+**Files:** `apple/SkrepkaApp/Effects.swift:8-30`
317+
318+The `NoRedirectDelegate` cancels all HTTP redirects. This prevents a hostile relay from
319+answering `/poll` or `/messages` with a 302 to an attacker-controlled host, which would replay
320+the `Authorization: Bearer` header to the redirect target. The delegate returns `nil`, which
321+hands the 3xx response to the caller as a non-2xx status. The core treats it as a failed
322+request. Correct.
323+
324+**Verdict:** Safe. Good defense.
325+
326+### ⚪ N18. Keychain uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — key never rides backup
327+
328+**Type:** Implementation review (Effects.swift)
329+**Files:** `apple/SkrepkaApp/Effects.swift:352`
330+
331+The identity key is stored with `ThisDeviceOnly` accessibility, meaning it never enters iCloud
332+or iTunes backups. The key is accessible after first unlock (not only when unlocked), so
333+background polling works. This is the correct accessibility class for a messaging identity key.
334+
335+**Verdict:** Safe. Good practice.
336+
337+---
338+
339+## Summary of New Findings
340+
341+| # | Severity | Type | Title |
342+|---|----------|------|-------|
343+| N1 | 🔴 critical | Design gap | No AAD — `to` field unauthenticated, silent targeted censorship |
344+| N2 | ⚪ info | Code hygiene | HKDF info and wire version byte are independent constants |
345+| N3 | 🟡 medium | DoS | appendMessage dedup O(n) — identical-blob replay amplifies to O(n × batch) |
346+| N4 | 🟡 medium | Impl bug | capForwards non-deterministic eviction when retry counts equal |
347+| N5 | 🟡 medium | Design gap | fromServer not verified against connecting peer address |
348+| N6 | 🔵 low | Info | genToken entropy waste (one digit per call) |
349+| N7 | 🟡 medium | Impl concern | seen_ids dedup set unbounded — grows without limit |
350+| N8 | ⚪ info | Safe | Cursor crash window — persisted cursor is safe |
351+| N9 | 🟡 medium | Impl concern | Budget exhaustion livelock — client stuck if relay always serves oversized page |
352+| N10 | ⚪ info | Safe | Profile ts >= replay is a no-op |
353+| N11 | ⚪ info | Safe | Negative ts clamped to 0 is harmless |
354+| N12 | ⚪ info | Safe | ack_ids non-string filtering is correct |
355+| N13 | 🟠 high | Design gap | Gossip amplification 100x — O(100 × |messages|) per request |
356+| N14 | 🟡 medium | Design gap | Offline events don't cancel pending forwards |
357+| N15 | 🟡 medium | Deploy | install.sh checksum is best-effort — missing checksum silently proceeds |
358+| N16 | ⚪ info | Safe | install.sh DOMAIN validation is correct |
359+| N17 | ⚪ info | Safe | Redirect cancellation prevents token exfiltration |
360+| N18 | ⚪ info | Safe | Keychain ThisDeviceOnly is correct |
361+
362+**Critical (1):** N1
363+**High (1):** N13
364+**Medium (6):** N3, N4, N5, N7, N9, N14, N15
365+**Low (1):** N6
366+**Info (7):** N2, N8, N10, N11, N12, N16, N17, N18
367+
368+---
369+
370+## Priorities
371+
372+1. **N1 (critical):** Add AAD to AEAD. This is the most impactful finding — it enables
373+ undetectable censorship by any relay. Requires wire-format change (v0x02).
374+
375+2. **N13 (high):** Document or limit gossip amplification. A single request triggers 100
376+ mailbox scans. Cap newly-online keys that trigger forwards per batch.
377+
378+3. **N9 (medium):** Fix budget exhaustion livelock. Advance cursor to last processed event
379+ instead of restoring to pre-page cursor. This prevents a malicious relay from permanently
380+ wedging the client with oversized poll pages.
381+
382+4. **N7 (medium):** Bound `seen_ids` set. An unbounded dedup set that's rewritten on every
383+ message receipt will degrade performance over time.
384+
385+5. **N3 (medium):** Index `*messages` for dedup. The O(n) scan is a CPU amplification vector.
AUDIT-NEW.mdnew file+816
@@ -0,0 +1,816 @@
1+# Skrepka Security Audit — New Findings (2026-07-15)
2+
3+**Scope:** Exhaustive multi-pass audit of PROTOCOL.md, server.knot, and core/src/ (app.rs, crypto.rs, protocol.rs, model.rs, phonemic.rs, ffi.rs). Findings below are **not** documented in PROTOCOL-REVIEW.md.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. Cryptographic Protocol Weaknesses
10+
11+### 🔴 Q1. HKDF salt omits sender identity — KCI/UKS not prevented by construction
12+
13+**Type:** Design gap (protocol + crypto.rs)
14+**Files:** `core/src/crypto.rs:276-290` (`derive_key`), PROTOCOL.md §3 step 3
15+
16+The HKDF salt is `ephemeral_public || recipient_x25519_public` — both public values, neither identifying the sender. The encryption key is derived entirely from the ephemeral X25519 exchange and the recipient's public key. While the Ed25519 signature inside the AEAD binds the sender, the *key derivation* itself has no sender contribution.
17+
18+This means anyone who knows the recipient's private key can compute the same shared secret from a captured ephemeral public key — there's no sender contribution to the KDF. The signature is the only thing binding the sender, and it's verified *after* AEAD decryption. An attacker who obtains the recipient's private key can decrypt all captured blobs (already noted as "no forward secrecy" in §10), but additionally, because the KDF has no sender binding, an attacker can *replace* the sender identity and signature inside a captured ciphertext by re-encrypting with the same ephemeral key — wait, no, they can't re-encrypt without the shared key. However, if the attacker *has* the recipient's private key, they can decrypt, strip the original sender, forge a new inner buffer with their own sender key + signature over the same `recipient || compressed_len || compressed || padding`, re-encrypt with the derived key, and re-send. The recipient sees a valid message from a different sender. This is a *key compromise impersonation (KCI)* vector: compromising the recipient's key lets an attacker re-attribute captured messages.
19+
20+**Attack scenario:** An attacker records ciphertext between Alice and Bob. Later they compromise Bob's private key. They decrypt the blobs, replace Alice's identity with Mallory's (signing under Mallory's key), re-encrypt, and deliver to Bob. Bob sees messages apparently from Mallory.
21+
22+**Exploitable in practice:** Requires recipient key compromise, which the protocol already considers catastrophic. The KCI vector is an *additional* consequence of that compromise, not a standalone attack. Still, including the sender's public key in the HKDF salt or info would close it by construction.
23+
24+**Suggested fix:** Include `sender_ed25519_public` in the HKDF salt or info: `salt = ephemeral_public || recipient_x25519_public || sender_ed25519_public`. Since the sender pubkey is inside the AEAD, a decryptor already has it before verification, but the key derivation would need it — so the sender pubkey would need to be *outside* the AEAD (as associated data or in the blob header), which changes the wire format. Alternatively, accept this as a documented limitation of v0.1 (the signature already prevents forgery without key compromise).
25+
26+### 🟡 Q2. No associated data (AAD) on AEAD — ciphertext is malleable across contexts
27+
28+**Type:** Design gap (protocol + crypto.rs)
29+**Files:** `core/src/crypto.rs:384-387` (encrypt), `core/src/crypto.rs:434-438` (decrypt)
30+
31+XChaCha20-Poly1305 is used with **no associated data**: `cipher.encrypt(nonce, inner)` and `cipher.decrypt(nonce, ciphertext)`. The version byte, ephemeral public key, and nonce are all in the clear and not authenticated. An attacker who can modify the blob in transit (a malicious relay, or a network attacker without TLS) can flip the version byte or corrupt the ephemeral key without any AEAD failure — the AEAD only protects the ciphertext body.
32+
33+While corrupting these fields will cause decryption/verification to fail downstream (wrong key derivation, wrong version), there's no *cryptographic binding* between the outer envelope fields and the inner ciphertext. A relay could, for example, swap the ephemeral public key from one blob with that of another (keeping the same nonce + ciphertext), and the recipient would get a decryption failure rather than an authentication failure — a distinction that matters for error handling and oracle analysis.
34+
35+More importantly, the `to` field in the envelope is **not** authenticated by the AEAD. A relay can change the `to` field on a stored message, redirecting it to a different recipient. That recipient can't decrypt it (wrong key), but the *original* recipient's message is now gone from the mailbox. This is a denial-of-service vector that doesn't require breaking encryption.
36+
37+**Attack scenario:** A malicious relay swaps the `to` field on Alice's message to Bob, replacing it with Charlie's pubkey. Bob never receives the message. Charlie receives garbage (can't decrypt). Neither side gets an error from the protocol.
38+
39+**Exploitable in practice:** Yes — any relay can do this. However, a relay can already drop messages, so this is equivalent in impact to message loss, which is an accepted limitation.
40+
41+**Suggested fix:** Include `version_byte || ephemeral_public || nonce` as AAD in the AEAD. For the envelope-level `to` field, consider including `recipient_ed25519_public` as AAD (it's already in the signed inner buffer, but AAD would make the envelope integrity-checked too). This is a wire-format change (v0x02).
42+
43+### 🟡 Q3. compressed_len field is attacker-controllable and trusted for slicing
44+
45+**Type:** Implementation concern (crypto.rs)
46+**Files:** `core/src/crypto.rs:449-459`
47+
48+During decryption, `compressed_len` is read from the AEAD-decrypted inner buffer and used to slice the `compressed` region: `let compressed = &inner[100..100 + compressed_len]`. The AEAD protects the integrity of the inner buffer, so this field is authenticated — but only *after* decryption. The check `100 + compressed_len > inner.len()` prevents an out-of-bounds read, but a `compressed_len` that points into the padding region (i.e., `compressed_len < actual_compressed_data_len`) would silently feed truncated zstd data to the decompressor, which would fail. Conversely, `compressed_len > actual_compressed_len` would include padding bytes in the zstd frame, which the decompressor might or might not reject.
49+
50+Since the signature is over `recipient_pub || compressed_len_bytes || compressed || padding`, the signed region includes the `compressed_len` field. A tampered `compressed_len` would break the signature. So this is actually well-protected. The concern is that the signature is verified *after* the `compressed_len` is already used for slicing — but since the slice is only used for decompression *after* signature verification (`crypto.rs:472`), this is safe.
51+
52+**Verdict:** Not a bug after careful analysis — the signature covers `compressed_len` and the decompression happens after verification. Downgraded to info.
53+
54+### ⚪ Q4. zstd decompression bomb window before content-size cap
55+
56+**Type:** Implementation defense-in-depth (crypto.rs)
57+**Files:** `core/src/crypto.rs:303-322`
58+
59+The decompressor caps `window_log_max(21)` (2 MiB window) and output at `MAX_PLAINTEXT_LEN + 1` (1 MiB + 1 byte). This is solid. However, a crafted zstd frame with a small window but very long output (many small blocks) could still cause significant CPU consumption before the 1 MiB + 1 byte limit triggers. The `take(MAX_PLAINTEXT_LEN + 1)` read will read one byte past the cap, forcing a full decompression attempt up to that point. This is bounded at ~1 MiB of output, so CPU is bounded too. This is fine.
60+
61+### 🟡 Q5. CRIME/BREACH surface is wider than documented for profile messages
62+
63+**Type:** Spec gap / design weakness (PROTOCOL.md §3)
64+**Files:** PROTOCOL.md §3, §4; `core/src/protocol.rs:73-91`
65+
66+The spec warns about CRIME/BREACH but says "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand." However, `profile` messages compress `display_name`, `bio`, and `photo` together in one zstd frame. If an attacker can influence part of the profile (e.g., the user sets their display name based on something an attacker controls, like a contact's name echoed back), and the profile is broadcast to contacts, the compressed size could leak information. The attacker would need to:
67+
68+1. Influence part of the plaintext that is compressed alongside a secret
69+2. Observe the resulting blob size (which is bucket-padded, so only the bucket boundary leaks)
70+
71+The padding buckets (256, 512, 1024, ...) are coarse enough that the leak is minimal in practice. But the spec's claim that "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand" is too strong: if an attacker sends messages that cause the user to change their display name or bio (e.g., "change your name to match mine"), the re-broadcast compresses attacker-influenced text alongside the user's photo (which is base64 and thus high-entropy/incompressible). The photo being base64 limits the CRIME surface since it's already nearly random.
72+
73+**Exploitable in practice:** Very unlikely given bucket padding and the difficulty of controlling what text gets compressed alongside secrets.
74+
75+**Suggested fix:** Clarify in the spec that the CRIME/BREACH surface extends to profile broadcasts (where attacker-influenced text and user-controlled secrets are compressed together), not just text messages.
76+
77+---
78+
79+## 2. Server Implementation Bugs
80+
81+### 🟠 Q6. `ackPolledMessages` is called before `doPoll` — races with concurrent sends to the same mailbox
82+
83+**Type:** Implementation bug (server.knot)
84+**Files:** `server.knot:1258-1287` (`handlePoll`), `server.knot:1343-1351` (`ackPolledMessages`)
85+
86+In `handlePoll`, the cursor-ack (`ackPolledMessages`) runs inside the setup atomic block, *before* `doPoll` collects events. If a concurrent `/messages` request arrives between the ack and the collect, the new message is appended with a fresh `receivedAt` that is strictly greater than the acked cursor — so it will be picked up by `doPoll`'s collect. This is actually correct behavior.
87+
88+However, there's a subtler issue: the cursor is clamped to `max(0 (min (max t curSeq) cursor))` before the ack. If the client sends a cursor that is *ahead* of `curSeq` (e.g., from a different relay), the clamp to `max t curSeq` could *lower* the cursor, which means `ackPolledMessages` acks less than the client intended. But this is intentional (the comment explains it).
89+
90+After deeper analysis, the ordering here is actually correct — the ack happens first (deleting old messages), then the collect picks up what's left that's newer than the cursor. The race with concurrent sends is handled by STM: the new message gets a fresh `receivedAt > cursor`, so it's picked up.
91+
92+**Verdict:** Not a bug after careful analysis. Downgraded to info.
93+
94+### 🟡 Q7. `appendMessage` dedup is O(n) per message — quadratic under burst
95+
96+**Type:** Implementation performance issue (server.knot)
97+**Files:** `server.knot:919-937` (`appendMessage`)
98+
99+`appendMessage` checks `any (\\m -> m.toKey == toKey && m.encryptedBlob == encryptedBlob) msgs` — a full scan of `*messages` — on every call. For a batch of 100 messages to different recipients, this is 100 × |messages| comparisons. With `maxMessagesPerRecipient = 10000` and many recipients, this can be tens of thousands of comparisons per message. Under sustained throughput this is quadratic.
100+
101+This is documented in the file header ("Everything is intentionally O(n) over flat lists; performance is sacrificed for clarity"), so it's a known design choice, not a security finding per se. But it creates a **DoS amplification** vector: an authenticated sender can send 100 messages per request (up to 60 requests/min = 6000/min), each triggering a full scan of the mailbox. With 10,000 messages per recipient and many recipients, each scan is O(total_messages), making the CPU cost per request proportional to the entire mailbox size.
102+
103+**Attack scenario:** An attacker authenticates (free — just generate a keypair) and sends 100-message batches of junk to many recipients. Each message in the batch triggers a full `*messages` scan for dedup. With a large mailbox, this can consume significant CPU per request.
104+
105+**Exploitable in practice:** Bounded by rate limits (60/min per IP) and the fact that the attacker must be authenticated. The CPU amplification is real but modest.
106+
107+**Suggested fix:** Index `*messages` by `(toKey, encryptedBlob)` or use a hash-based dedup set.
108+
109+### 🟠 Q8. `maxMessagesPerRecipient` eviction destroys genuine mail under flood
110+
111+**Type:** Design gap (server.knot)
112+**Files:** `server.knot:156-182`, `server.knot:926-937`
113+
114+This is documented in the code comments as a "trade, not a fix," but it constitutes a real attack: an authenticated sender (pubkeys are free to mint) can flood a recipient's mailbox with 10,000 junk messages, pushing out all genuine undelivered messages. The recipient's client only sees junk. The comment says "at least the newest messages always land" — but the newest messages are the *attacker's junk*, not the genuine messages that were already queued.
115+
116+The `MAX_POLL_TOTAL_BYTES` client-side budget (64 MiB) means the client processes a bounded amount per poll, but the server has already destroyed the genuine messages by eviction. The client never sees them.
117+
118+**Attack scenario:**
119+1. Victim is offline for a day. Attacker sends 10,000 messages to victim's pubkey.
120+2. Each message is a valid encrypted blob (attacker generates a keypair and encrypts real messages to the victim — they don't need to be decryptable by the victim, just valid blobs).
121+3. Victim comes online, polls, and gets a page of junk. Their genuine messages were evicted.
122+
123+**Exploitable in practice:** Yes — the attacker needs to be authenticated (free) and send 10,000 messages. At 60 requests/min × 100 messages = 6,000/min, this takes ~2 minutes. The rate limit is per-IP, so the attacker needs multiple IPs or time.
124+
125+**Suggested fix:** This is an accepted design tradeoff in the current implementation. A real fix requires per-sender quotas or pricing. Document it in PROTOCOL.md §10 as a DoS vector, not just a capacity concern.
126+
127+### 🟡 Q9. `capForwards` evicts by retry count, not by age — fresh forwards can be dropped
128+
129+**Type:** Implementation bug (server.knot)
130+**Files:** `server.knot:515-522` (`capForwards`), `server.knot:109-110` (`maxForwardsTotal`)
131+
132+`capForwards` keeps the lowest-retry entries (`take maxForwardsTotal (sortBy (\\f -> f.retries) fwds)`). When the outbox exceeds `maxForwardsTotal = 50000`, the *highest-retry* entries are dropped. The comment says these are "closest to `maxForwardRetries` (about to be abandoned anyway)."
133+
134+But there's a subtle issue: a fresh forward (retries: 0) for a *healthy* peer could be dropped if the outbox is full of fresh forwards (all retries: 0) for *failing* peers. The sort is by `retries` only, not by `retries` then by age or destination health. When many entries share `retries: 0`, the `sortBy` is unstable (Knot's sort may not be stable), so *any* retries-0 entry could be dropped — including one for a healthy peer that would have succeeded on the first try.
135+
136+**Attack scenario:** An attacker floods the forwards outbox by gossipping many fake "online" events, causing `forwardQueuedToServer` to stage forwards for many fake servers. Each fails and increments retries. When `maxForwardsTotal` is reached, genuine forwards for healthy peers may be evicted alongside the junk.
137+
138+**Exploitable in practice:** Requires federation to be enabled and the attacker to be able to reach `/federation/gossip`. The genuine forward would be re-staged on the next presence announce, so the impact is a delivery delay, not permanent loss.
139+
140+**Suggested fix:** Sort by `(retries, age)` or skip entries for peers in backoff before capping.
141+
142+### 🟡 Q10. `genToken` entropy is 4 bits per call — collision-resistant but timing-leaky
143+
144+**Type:** Implementation concern (server.knot)
145+**Files:** `server.knot:532-537`
146+
147+`genToken` generates one hex character per recursive call, drawing from `randomInt 16`. Each call to `randomInt` produces 4 bits. For 48 chars (192 bits), this is 48 calls to `randomInt`. The concern is that `randomInt` on the Knot runtime may not be a CSPRNG — the function signature is `IO {random}` which suggests a runtime-provided random source, but the implementation is not visible here.
148+
149+If `randomInt` uses a non-cryptographic PRNG (e.g., a LCG), the 192-bit tokens would be predictable. The auth challenge tokens and session tokens both use `genToken`. If predictable, an attacker could predict future session tokens after observing some.
150+
151+**Exploitable in practice:** Depends on the Knot runtime's `randomInt` implementation, which is not in this codebase. If it uses `/dev/urandom` or equivalent, this is fine. If it uses a userspace PRNG seeded from a low-entropy source, this is critical.
152+
153+**Suggested fix:** Verify that the Knot runtime's `randomInt` uses a CSPRNG. Document the assumption.
154+
155+### 🟡 Q11. `handleVerifyAuth` challenge lookup is O(n) — timing varies with challenge pool size
156+
157+**Type:** Implementation concern (server.knot)
158+**Files:** `server.knot:1210-1214`
159+
160+The challenge verification does `any (\\c -> same c && c.expiresAt > t && matchIp clientIp c.ip) ch` — a linear scan of `*challenges`. With `maxChallenges = 10000`, this is up to 10,000 comparisons per verify. The timing varies with the position of the matching challenge in the list, which could be observable.
161+
162+Combined with the already-documented token comparison timing side-channel (finding B), this adds another timing channel: the time to find the matching challenge leaks its position in the list, which leaks when it was inserted (roughly when the `/auth/challenge` was called).
163+
164+**Exploitable in practice:** Very low impact — the challenge is consumed immediately after verification, and the timing difference is small relative to network jitter.
165+
166+**Suggested fix:** Index challenges by `(pubkey, challenge)` for O(1) lookup.
167+
168+### 🟡 Q12. `handleVerifyAuth` computes signature verification outside atomic — TOCTOU on challenge
169+
170+**Type:** Implementation gap (server.knot)
171+**Files:** `server.knot:1196-1214`
172+
173+The `sigValid` computation is done outside the atomic block (line 1204-1209), but the challenge lookup is inside the atomic (line 1213). Between computing `sigValid` and the atomic block, the challenge could be consumed by a concurrent `/auth/verify` with the same challenge. But the atomic block re-checks: `any (\\c -> same c && ... && c.expiresAt > t && matchIp clientIp c.ip) ch && sigValid`. The `sigValid` is pre-computed, so the check is: challenge exists AND signature is valid. If the challenge is consumed between the pre-compute and the atomic, the `any` returns false, and the verify fails. This is safe — no TOCTOU.
174+
175+But there's a subtler issue: the `clientIp` for the challenge (set at `/auth/challenge` time) and the `clientIp` for the verify (set at `/auth/verify` time) must match (`matchIp clientIp c.ip`). If the client's IP changes between challenge and verify (mobile network change), the verify fails. This is documented as expected behavior. But if `trustForwardedFor` is true and the client can spoof `X-Forwarded-For`, they could set a different IP on the challenge and verify requests — but they'd need to match, so they'd just set the same spoofed IP on both. This is already documented (finding 8).
176+
177+**Verdict:** Not a new bug. The pre-compute is safe because the atomic block re-validates the challenge's existence.
178+
179+### 🟠 Q13. `revokeOthers` race — concurrent sessions for same pubkey can race on revoke
180+
181+**Type:** Implementation bug (server.knot)
182+**Files:** `server.knot:1219-1231`
183+
184+When `revokeOthers: true`, the handler does:
185+```
186+let pruned = if revokeOthers
187+then filter (\\s -> s.pubkey != pubkey) ses
188+else capPerKey ses pubkey maxSessionsPerKey
189+```
190+
191+This drops *all* other sessions for this pubkey. But if a concurrent `/auth/verify` for the same pubkey is in flight (without `revokeOthers`), it could install a new session between the `filter` and the `union`:
192+
193+1. Verify A (revokeOthers=true) reads `*sessions`, filters out all pubkey sessions → `pruned`
194+2. Verify B (revokeOthers=false) reads `*sessions` (still has the old sessions), adds a new one → commits
195+3. Verify A commits `pruned + newSession` → Verify B's session is lost
196+
197+But wait — both are inside `atomic` blocks, and STM guarantees serializability. If Verify A's atomic block retries after Verify B commits, Verify A re-reads `*sessions` (now including B's session) and re-applies the filter — so B's session would be dropped. This is actually the *intended* behavior of `revokeOthers`: it should drop all other sessions. The "race" is really just STM serializing correctly.
198+
199+**Verdict:** Not a bug. STM handles this correctly. Downgraded to info.
200+
201+### 🟡 Q14. `pruneExpired` presence offline gossip may miss peers that expired in a prior sweep
202+
203+**Type:** Implementation concern (server.knot)
204+**Files:** `server.knot:1614-1633` (`backgroundPrune`)
205+
206+The prune loop snapshots peers *before* the sweep (`prePeers`) and unions with *post-sweep* peers. But `pruneExpired` returns only locally-expired keys that have no other live local session. If a key expired in a *previous* sweep (and was already removed from `*presence`), the current sweep has nothing to return for it. The offline gossip for that key was already sent in the previous sweep. This is correct — each key gets exactly one offline event.
207+
208+**Verdict:** Not a bug. The pre/post union handles the edge case correctly.
209+
210+### 🟡 Q15. `forwardQueuedToServer` budget check reads `*forwards` count but doesn't reserve atomically
211+
212+**Type:** Implementation concern (server.knot)
213+**Files:** `server.knot:1140-1151`
214+
215+`forwardQueuedToServer` computes `budget = max 0 (maxForwardsPerServer - existing)` where `existing = countWhere (\\f -> f.toServer == server) fwds`, then takes `take budget` candidates and unions them into `*forwards`. The whole thing is inside one `atomic` block, so it's serializable — two concurrent calls would serialize, and the second would see the first's additions. This is correct.
216+
217+**Verdict:** Not a bug. STM handles concurrency correctly.
218+
219+---
220+
221+## 3. Client Core Logic Bugs
222+
223+### 🟠 Q16. `ingest_poll` cursor clamping uses `now_ms() + 60_000` — relay can advance cursor beyond actual messages
224+
225+**Type:** Implementation concern (app.rs)
226+**Files:** `core/src/app.rs:1967-1975`
227+
228+The cursor is clamped to `page.cursor.min(now_ms().saturating_add(60_000))`. If a hostile relay sends `cursor = now_ms() + 59_000`, the client accepts it, writes it to disk, and the next poll sends this cursor to the relay. If the client later switches to a *different* relay, the cursor is reset to 0 (line 868) — so this is per-relay and not cross-relay. But on the *same* relay, a hostile relay can send an artificially high cursor, causing the client to ack (delete) all messages up to that cursor on the next poll — even messages the client hasn't seen yet.
229+
230+Wait — the cursor is what the *client* sends to the *server*. The server uses it to ack (delete) messages ≤ cursor. If a relay sends a high cursor in the poll response, the client adopts it and sends it back on the next poll. The relay then deletes everything ≤ that cursor. If there were messages the client never received (because the relay withheld them), they're now permanently lost.
231+
232+But the relay already controls what messages it sends to the client — it could simply withhold messages and never show them. The cursor doesn't give the relay any new power it doesn't already have. The relay can always just not send events. So this is not a new attack vector.
233+
234+However, there's a subtler issue: the cursor is persisted to disk. If the relay sends a high cursor and the client adopts it, then the client reconnects to the same relay after a network interruption, the cursor is still high. Any messages that arrived at the relay during the interruption (with `receivedAt` ≤ the poisoned cursor) are automatically acked and deleted.
235+
236+**Attack scenario:** A malicious relay sends `cursor = now_ms() + 59000` in an empty poll response. The client adopts it and persists it. Later, legitimate messages arrive at the relay with `receivedAt` values below the poisoned cursor. The client's next poll sends the poisoned cursor, and the relay deletes those messages.
237+
238+But wait — the relay assigns `receivedAt` using its own monotonic sequence. If the relay is malicious, it can just not deliver messages. If it's honest but was briefly compromised, the poisoned cursor could cause message loss after recovery. This is a real concern for the "briefly compromised relay" scenario.
239+
240+**Exploitable in practice:** A compromised relay can cause permanent message loss for messages that arrive during the compromise window. The 60-second clamp limits the window, but the cursor persists across reconnections.
241+
242+**Suggested fix:** Only adopt the cursor if the poll response contained events. If `events` is empty and `cursor > model.cursor`, don't advance the cursor — the relay has nothing to tell us, and a cursor advance from an empty page is suspicious.
243+
244+### 🟡 Q17. Delivery ack staleness check uses `ts` not `last_ack_ts` watermark for ordering — race with clock skew
245+
246+**Type:** Implementation concern (app.rs)
247+**Files:** `core/src/app.rs:2116-2134`
248+
249+The ack staleness check compares `ts < c.last_ack_ts`. If two acks arrive in the same poll page from the same sender, with `ts_A = 100` and `ts_B = 200`, and `last_ack_ts = 50`:
250+- Ack A (ts=100): not stale (100 >= 50), applies, sets `last_ack_ts = min(100, now)`
251+- Ack B (ts=200): not stale (200 >= 100), applies, sets `last_ack_ts = min(200, now)`
252+
253+This is correct. But if the page order is reversed (B before A), and `last_ack_ts = 50`:
254+- Ack B (ts=200): not stale, applies, sets `last_ack_ts = min(200, now)`
255+- Ack A (ts=100): stale (100 < min(200, now)), dropped!
256+
257+This means an older ack in the same page as a newer one is dropped if it's processed after the newer one. Since the events are processed in page order (not sorted), a relay could reorder events to cause an older ack to be dropped. The impact is minimal — the older ack would have acked fewer or the same messages.
258+
259+**Exploitable in practice:** A malicious relay could reorder events to suppress a specific ack, but it could also just drop the event entirely. Low impact.
260+
261+**Suggested fix:** Process acks in `ts` order within a page, or collect all acks from a sender and apply the highest-ts one.
262+
263+### 🟡 Q18. `seen_ids` dedup set is built lazily from conversation — misses messages trimmed by `MAX_MESSAGES_PER_PEER`
264+
265+**Type:** Implementation bug (app.rs)
266+**Files:** `core/src/app.rs:2086-2092`
267+
268+The dedup set is built from the current conversation: `convo.iter().map(|m| m.id.clone()).collect()`. But `trim_history` may have aged out older messages (keeping only the most recent 1000). A replayed message whose `id` matches an aged-out message would pass the dedup check and be re-inserted into the conversation. The message would appear as a new message (with its original `ts`), potentially at the top of the conversation list if `ts` is high.
269+
270+**Attack scenario:** An attacker captures a message blob (by being a relay, or by gossip redirect), waits until the recipient's conversation has been trimmed past that message (1000+ messages later), then replays the blob. The dedup check misses it (the `id` is no longer in the trimmed conversation), and the message is re-inserted. The message appears as a new old-dated message.
271+
272+**Exploitable in practice:** Requires the attacker to have captured the blob and to wait for 1000+ messages. The replayed message would be inserted at its original `ts` position, so it wouldn't appear at the bottom of the chat — it would appear in its chronological position. The user might not notice. The impact is minimal — it's an old message reappearing, not new content.
273+
274+**Suggested fix:** Maintain a persistent dedup set (not derived from the trimmed conversation), or store the set of seen IDs separately from the message history.
275+
276+### 🟡 Q19. Profile `ts` staleness check uses `>=` — equal-timestamp replay overwrites with identical content (harmless) but also overwrites with *different* content if sender crafts it
277+
278+**Type:** Implementation concern (app.rs)
279+**Files:** `core/src/app.rs:2174-2179`
280+
281+The profile staleness check uses `ts >= c.last_profile_ts`, meaning a replayed profile at the *same* `ts` is accepted. The comment says "a replayed profile at the same `ts` overwrites with identical content, which is a no-op in practice." But this assumes the replayed blob has the same content. An attacker who captures a profile blob can modify the *inner* content (display_name, bio, photo) and re-encrypt it with the same `ts` — but they'd need to sign it, and they can only sign with their own key. The recipient would see it coming from the same sender (the original profile sender), but the signature would be from the original sender's key... wait, no. The attacker can't re-sign with the original sender's key unless they have it.
282+
283+Actually, the attacker *replays the exact same blob* — same ciphertext, same inner content, same signature. The `ts` is the same, and the content is the same. So it's truly a no-op. The attacker can't modify the content without breaking the signature. This is safe.
284+
285+**Verdict:** Not a bug. The `>=` is correct and the replay is a true no-op.
286+
287+### 🟡 Q20. `flush_next` batch-building skips items for different recipients — outbox may have interleaved items stuck behind a large batch
288+
289+**Type:** Implementation concern (app.rs)
290+**Files:** `core/src/app.rs:1818-1853`
291+
292+The batch stops at the first recipient change (`if recipient.as_ref().is_some_and(|r| r != &item.recipient) { break }`). If the outbox has items for recipient A followed by items for recipient B, and A's batch is being built, B's items wait. This is by design (FIFO fairness). But if A's send fails repeatedly, B's items are stuck behind A's retries. The `retry_batch` function charges retries and eventually drops A's items (after `MAX_OUTBOX_RETRIES` or `OUTBOX_TTL_MS`), unblocking B.
293+
294+This is correct behavior, not a bug. The FIFO ordering ensures fairness at the cost of head-of-line blocking, which is the right tradeoff for a messaging app (you don't want a failed send to one peer to reorder sends to another).
295+
296+**Verdict:** Not a bug. Downgraded to info.
297+
298+### 🟡 Q21. `encrypt_for` uses `OsRng` — no deterministic test path, but also no RNG failure handling
299+
300+**Type:** Implementation concern (app.rs)
301+**Files:** `core/src/app.rs:2227-2231`
302+
303+`encrypt_for` uses `let mut rng = rand_core::OsRng;` and passes it to `crypto::encrypt`. If `OsRng` fails (e.g., the OS entropy pool is exhausted on a freshly booted device), `rng.fill_bytes` would panic or return an error. `crypto::encrypt` calls `rng.fill_bytes` in multiple places (`eph_priv`, `padding`, `nonce`), and these would propagate the error up as `CryptoError::Encrypt` (since `fill_bytes` on `OsRng` infallibly fills — it uses `/dev/urandom` which never blocks on modern systems).
304+
305+**Verdict:** `OsRng` is infallible on iOS/macOS (it uses `SecRandomCopyBytes` which always succeeds). Not a bug.
306+
307+---
308+
309+## 4. Wire Format / Parsing Issues
310+
311+### 🟠 Q22. `isValidHex` uses `chars` (set semantics) — deduplication hides length-based attacks
312+
313+**Type:** Implementation bug (server.knot)
314+**Files:** `server.knot:542-545`
315+
316+```knot
317+isValidHex = \s ->
318+ let n = length s in
319+ n > 0 && n / 2 * 2 == n && all (\c -> contains c "0123456789abcdef") (chars s)
320+```
321+
322+`chars s` returns the **distinct** character set (relations dedupe). So for a string like `"aaaa"`, `chars` returns `{"a"}`, and the `all` check passes. The `length` check (`n / 2 * 2 == n`) ensures even length. But `chars` deduplication means the `all` check is O(distinct chars) not O(string length), which is a performance optimization, not a correctness issue — the set membership test covers all characters.
323+
324+This is actually correct: if all *distinct* characters are in the hex alphabet, then all characters are in the hex alphabet. The dedup just makes it faster.
325+
326+**Verdict:** Not a bug. The set semantics are correct for membership checking.
327+
328+### 🟡 Q23. `PubkeyHex` type accepts non-curve-point 32-byte hex values at the route boundary
329+
330+**Type:** Implementation gap (server.knot)
331+**Files:** `server.knot:322` (`type PubkeyHex = Text where isValidExactHex 64`)
332+
333+The `PubkeyHex` type only checks that the string is 64 lowercase hex characters — it doesn't verify the decoded bytes form a valid Ed25519 public key (a valid curve point). The client's `try_parse_pubkey` (`phonemic.rs:140-144`) does check this via `VerifyingKey::from_bytes`, but the server accepts any 64-hex-char string as a valid `PubkeyHex`.
334+
335+This means a client can send a message to a `to` address that is 32 bytes of hex but not a valid Ed25519 public key. The server stores and forwards the blob. The "recipient" can never decrypt it because `ed25519_pk_to_x25519` would fail on the invalid key. The blob sits in the mailbox until TTL expiry, consuming storage.
336+
337+**Attack scenario:** An attacker sends 100 messages per batch to invalid pubkeys. Each is stored for 30 days. With 60 requests/min × 100 = 6000/min, after 1 hour that's 360,000 junk messages. Each counts against `maxMessagesPerRecipient` for the target — but the target is a non-existent key, so nobody is affected. The real concern is storage consumption: 360,000 × minBlobLen bytes = ~62 MB per hour per attacker IP.
338+
339+**Exploitable in practice:** The rate limit (60/min per IP) bounds the rate. The 30-day TTL bounds the total. `maxMessagesPerRecipient = 10000` per non-existent key limits per-key. The total storage impact is bounded.
340+
341+**Suggested fix:** The server could optionally validate that `to` is a valid curve point, but this adds CPU cost and the spec doesn't require it (the server can't decrypt anyway). Document as accepted.
342+
343+### 🟡 Q24. `parse_payload` `ts` field defaults to 0 when missing — allows pre-epoch ordering manipulation
344+
345+**Type:** Implementation concern (protocol.rs)
346+**Files:** `core/src/protocol.rs:100`
347+
348+`let ts = v.get("ts").and_then(|t| t.as_i64()).unwrap_or(0);`
349+
350+If a peer omits the `ts` field (or sets it to a non-integer), it defaults to 0. In `ingest_poll`, `ts` is clamped: `parsed.ts.max(0).min(now.saturating_add(MAX_FUTURE_SKEW_MS))`. A `ts` of 0 passes the `max(0)` check and is used as-is. A message with `ts = 0` would be inserted at position 0 in the conversation (earliest), and `trim_history` would age it out first. This is harmless.
351+
352+But a peer could set `ts` to a very large negative number (e.g., `i64::MIN`), which would be clamped to 0. Same result. Or a peer could set `ts` to a float (e.g., `1.5`), which `as_i64()` returns `None` for, defaulting to 0. All of these are harmless because the clamping handles them.
353+
354+**Verdict:** Not a bug. The clamping in `ingest_poll` handles all edge cases.
355+
356+### 🟡 Q25. JSON parsing of poll response uses `unwrap_or_default()` — malformed JSON silently produces empty page
357+
358+**Type:** Implementation concern (app.rs)
359+**Files:** `core/src/app.rs:1309`
360+
361+`let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();`
362+
363+If the relay sends malformed JSON (or a valid JSON with wrong types), this silently defaults to an empty `PollResp` (empty events, cursor 0). The cursor of 0 would be adopted (clamped), and the next poll would re-fetch from the beginning — re-receiving all messages the client has already seen. The client's dedup (by `id` for text, by `ts` for acks and profiles) would handle the duplicates, but it wastes bandwidth and CPU.
364+
365+A hostile relay could exploit this by sending malformed JSON instead of a real response, forcing the client to re-poll from cursor 0 repeatedly. But the client would just re-process old messages (all deduped) and eventually get the real cursor from a subsequent response. The impact is wasted bandwidth, not data loss.
366+
367+**Suggested fix:** If JSON parsing fails, don't adopt the cursor and back off instead of treating it as an empty page.
368+
369+### 🟡 Q26. `delivery.ack` ack_ids filtering silently drops non-string entries — count vs. filtered count mismatch
370+
371+**Type:** Implementation concern (protocol.rs)
372+**Files:** `core/src/protocol.rs:116-126`
373+
374+The ack_ids parsing filters non-string entries: `raw.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()`. The raw count check (`raw.len() > MAX_ACK_IDS`) happens before filtering, but the post-filter check (`ack_ids.len() > MAX_ACK_IDS`) also happens. However, between the two checks, a peer could pack an array with `MAX_ACK_IDS` entries, some of which are non-strings (e.g., numbers). The raw check passes (`MAX_ACK_IDS` entries), the filter drops some, and the post-check passes (fewer than `MAX_ACK_IDS` strings). This is correct — the filtered count is what matters.
375+
376+But there's a subtlety: a peer could pack `MAX_ACK_IDS + 1` raw entries, all non-strings. The raw check fails (`MAX_ACK_IDS + 1 > MAX_ACK_IDS`), and the payload is dropped. But if the peer packs `MAX_ACK_IDS` raw entries, all non-strings, the raw check passes, the filter drops all of them, and `ack_ids` is empty. The empty ack is then sent to the sender, who processes it (marking nothing as delivered). This is harmless.
377+
378+**Verdict:** Not a bug. The double-check handles this correctly.
379+
380+---
381+
382+## 5. Federation Protocol Issues
383+
384+### 🟠 Q27. Gossip amplification — single `online` event triggers forward of *all* queued messages for a key
385+
386+**Type:** Design gap (server.knot)
387+**Files:** `server.knot:1522-1556` (`handleRecvGossip`)
388+
389+When a `online` event arrives for a key, `forwardQueuedToServer` sweeps the entire mailbox for messages to that key and stages them for forwarding. An attacker can send a single gossip request with 100 `online` events for 100 different keys, each triggering a full mailbox scan and forward staging. This is O(100 × |messages|) per request.
390+
391+More importantly, the attacker can repeatedly send `online` events for the same key. Each time, `forwardQueuedToServer` checks `notYetForwarded` and `notYetDelivered` to avoid duplicates, so re-sending the same event is a no-op for already-forwarded messages. But if the forward failed (404 from the attacker's own server, which doesn't actually have the key online), the forward entry stays in `*forwards` with `retries: 0`. A re-announce doesn't re-stage it (it's already in `*forwards`), but `retryPendingForwards` will retry it on the next sweep.
392+
393+The amplification is: one gossip request (100 events) → 100 mailbox scans + potentially 100 forward entries. Each forward entry triggers an HTTP POST to the `fromServer`. If the `fromServer` is the attacker, they receive 100 forwarded blobs — each a copy of the victim's ciphertext. This is the same as the documented gossip-redirect attack (#2), but the amplification factor (100 events per request) is not documented.
394+
395+**Exploitable in practice:** Same as finding #2, but with 100× amplification per request. The rate limit (60/min per IP) bounds the rate to 6,000 redirected keys per minute per IP.
396+
397+**Suggested fix:** Document the amplification factor. Consider limiting the number of newly-online keys per gossip batch that trigger forwards.
398+
399+### 🟡 Q28. Federation `offline` events don't trigger cleanup of pending forwards
400+
401+**Type:** Design gap (server.knot)
402+**Files:** `server.knot:1535-1541`
403+
404+When an `offline` event is received, the presence row for that `(pubkey, fromServer)` is removed, but pending forwards to that server are *not* cancelled. The forwards stay in `*forwards` and will be retried by `retryPendingForwards` until they either succeed (unlikely — the key is offline at that server), fail 10 times, or the message TTL expires.
405+
406+If the offline event was legitimate (the key actually went offline at that server), the forwards will waste retry attempts for up to `retryBackoffMax × maxForwardRetries` = 8 min × 10 = 80 minutes. During that time, the forwards occupy slots in `maxForwardsPerServer` and `maxForwardsTotal`.
407+
408+If the offline event was spoofed by an attacker (to clear a presence row), the forwards are still retried — and if the key is actually still online at that server, the forward succeeds on retry (the `no_presence` gate would pass because the key has a local session). But the presence row was removed, so `hasLocalPresence` at the peer would fail... wait, the presence row is on *our* side. The peer's presence is independent. The forward goes to the peer, and the peer checks its own local presence. If the key is still online at the peer, the forward succeeds. If not, it gets 404 and is retried.
409+
410+**Exploitable in practice:** An attacker can send fake `offline` events to clear presence rows, but the forwards continue independently. The impact is minimal — presence rows and forwards are decoupled.
411+
412+**Suggested fix:** When processing an `offline` event, optionally drop pending forwards to that server for the offline key (they'll never succeed if the key is truly offline there). But this could cause message loss if the offline event is spoofed. Better to leave as-is.
413+
414+### 🟡 Q29. `fromServer` in gossip events is not verified against the connecting peer's actual address
415+
416+**Type:** Design gap (server.knot)
417+**Files:** `server.knot:1511-1556` (`handleRecvGossip`)
418+
419+The `fromServer` field is a `ServerName` (validated for format), but it's not verified against the actual IP address of the connecting peer. An attacker can connect from IP `1.2.3.4` and claim `fromServer: "legit-relay.example.com"`. The server writes presence rows with `server: "legit-relay.example.com"` and forwards queued messages there. This is the same as the documented gossip-redirect attack (#2), but with a twist: the attacker can claim to *be* a legitimate relay, poisoning the presence table with the legitimate relay's hostname.
420+
421+When the *real* `legit-relay.example.com` later gossips, its events update the presence rows. But the attacker's rows may still be present (different `expiresAt`), and the server has multiple presence rows for the same key on different servers — which is by design. The attacker's row points to `legit-relay.example.com`, so forwards go to the legitimate relay, which doesn't have the key online — and returns 404. The legitimate relay gets junk forward requests.
422+
423+**Exploitable in practice:** This is a variant of the documented gossip-redirect, but with the attacker impersonating a legitimate relay. The legitimate relay receives spurious forward requests (bounded by `forwardRateLimit`).
424+
425+**Suggested fix:** Verify that the connecting peer's IP resolves to the claimed `fromServer` hostname (reverse DNS + forward DNS). This is the standard check for SMTP-like protocols. Blocked on `fetch` not exposing DNS.
426+
427+---
428+
429+## 6. Rate Limiting / DoS Vectors
430+
431+### 🟠 Q30. `/auth/challenge` has no per-pubkey rate limit beyond `maxChallengesPerKey = 5`
432+
433+**Type:** Design gap (server.knot)
434+**Files:** `server.knot:1166-1188` (`handleChallenge`)
435+
436+The challenge pool is globally capped at `maxChallenges = 10000` and per-key at `maxChallengesPerKey = 5`, with per-IP rate limiting at `authRateLimit = 60/min`. An attacker with many IPs (or behind a proxy with XFF spoofing when `trustForwardedFor = true`) can fill the challenge pool with challenges for random pubkeys. Each challenge is 60 seconds, and the pool LRU-evicts. But 10,000 challenges × 60 seconds means the pool is always full, and legitimate challenges are evicted.
437+
438+The comment says this is handled by LRU eviction: "an abandoned challenge may be dropped before its 60 s window elapses, which costs the affected client one retry." So the attacker can cause clients to retry their challenge/verify flow, adding latency but not blocking authentication.
439+
440+**Exploitable in practice:** With XFF spoofing (finding 8), the attacker gets unlimited rate-limit buckets. 60/min per IP × many IPs = thousands of challenges per minute. The pool (10,000) fills in ~3 minutes. After that, legitimate challenges are evicted, and clients must retry. This is a soft DoS — authentication still works but with extra latency.
441+
442+**Suggested fix:** Add a global rate on challenge creation (not just per-IP). Or cap the challenge creation rate per second regardless of source.
443+
444+### 🟡 Q31. `/messages` batch of 100 × 40 MiB would be rejected by body cap, but the rate limit counts requests not messages
445+
446+**Type:** Design gap (server.knot)
447+**Files:** `server.knot:233-234` (`sendRateLimit`), `server.knot:454` (`sendLimit`)
448+
449+The send rate limit is 60 requests/min per IP. Each request can carry up to 100 messages (bounded by the body cap). So the effective message rate is 60 × (body_cap / avg_blob_size) per minute. With a 42 MiB body cap and 42 KiB blobs, that's ~1000 messages per request × 60 = 60,000 messages/min. With 173-byte minimum blobs, it's ~251,000 messages/min per IP.
450+
451+This is documented in the code comment ("the effective message ceiling is higher"), but it means the per-IP rate limit is much less protective than it appears. An attacker with 10 IPs can send millions of messages per minute.
452+
453+**Exploitable in practice:** Bounded by the body cap (42 MiB) and the rate limit (60/min). The total data rate per IP is ~42 MiB × 60 = 2.5 GiB/min, which is significant.
454+
455+**Suggested fix:** Consider a per-message rate limit in addition to the per-request rate limit. Or reduce `maxBatchSize` for unauthenticated senders (but all senders are "authenticated" — they just have free keypairs).
456+
457+### 🟡 Q32. `maxSessions = 10000` LRU eviction is Sybil-floodable — documented but not in PROTOCOL.md §10
458+
459+**Type:** Design gap (server.knot)
460+**Files:** `server.knot:117-126`
461+
462+The code comment documents this: "global LRU eviction is Sybil-floodable: an attacker with enough IPs can evict live users' sessions." But it's not mentioned in PROTOCOL.md §10 as a DoS vector. An attacker with enough IPs can:
463+1. Generate 10,000 keypairs
464+2. Authenticate each one (60/min per IP, so ~167 IPs needed for one batch)
465+3. Fill the session pool
466+4. Legitimate users' sessions are evicted (they get 401 and must re-authenticate)
467+
468+This is a soft DoS — users re-authenticate automatically. But it adds latency and load.
469+
470+**Suggested fix:** Document in PROTOCOL.md §10. Consider per-IP session caps (rejected in the code comment as "collapses without XFF, punishes carrier-NAT") or a global session creation rate limit.
471+
472+### 🟡 Q33. `maxPresence = 100000` — gossip can fill presence table with fake keys
473+
474+**Type:** Design gap (server.knot)
475+**Files:** `server.knot:139-155`
476+
477+An attacker can send gossip with 100 fake `online` events per request (60/min per IP). Each event adds a presence row. With `maxPresencePerServer = 5000` per peer, a single attacker IP can add 5000 rows. With `maxPresence = 100000` globally, it takes 20 distinct attacker hostnames to fill the pool. Once full, legitimate presence rows are evicted by `capList` (LRU by `expiresAt`).
478+
479+Since gossip rows have `onlineGossipTtl = 90 min` and local rows have `sessionExpiry = 60 min`, local rows are evicted first (they expire sooner). The code handles this by exempting local rows from the cap:
480+
481+```
482+let localRows = filter (\\p -> isLocalServer p.server) merged
483+let remoteRows = filter (\\p -> not isLocalServer p.server) merged
484+*presence = union localRows (capList (capPerServer remoteRows from maxPresencePerServer) maxPresence)
485+```
486+
487+So local rows are always kept, and only remote (gossiped) rows are capped. An attacker can fill the remote pool, but local users' presence is never evicted. The impact is that legitimate *remote* presence (from honest peers) is evicted, causing forwards to those peers to fail.
488+
489+**Exploitable in practice:** Requires federation to be enabled and many distinct attacker hostnames (each passing `isBadServerName`). Bounded by `maxPresencePerServer = 5000` per hostname.
490+
491+**Suggested fix:** Document in PROTOCOL.md §10. Consider a per-request limit on newly-added presence rows (currently capped at `maxGossipEvents = 100` per request, which is already a bound).
492+
493+---
494+
495+## 7. Authentication / Session Management
496+
497+### 🟠 Q34. Session token is not bound to the pubkey — token theft grants full impersonation
498+
499+**Type:** Design gap (server.knot)
500+**Files:** `server.knot:356` (`type Session = {token: Text, pubkey: PubkeyHex, expiresAt: Timestamp, ip: Text}`)
501+
502+The session token is a bearer token — anyone who has it can use it. It's bound to `(pubkey, ip, expiry)`, but the `pubkey` binding is only used for *authorization* (e.g., rejecting self-sends), not for *authenticating the request*. The `authedPubkey` function looks up the token and returns the associated pubkey — it doesn't verify that the requester is the same entity that originally authenticated.
503+
504+If an attacker steals a bearer token (e.g., from a compromised proxy's access log), they can use it from the same IP (or any IP if `trustForwardedFor = false` or IP binding is disabled) to poll the victim's mailbox and send messages as the victim.
505+
506+The IP binding (`matchIp`) is the only thing preventing token replay from a different IP. But:
507+- If `trustForwardedFor = false`, IP binding is disabled (empty IP → wildcard).
508+- If the attacker is behind the same NAT/proxy as the victim, they share the IP.
509+- The token is sent in `Authorization: Bearer ***` over HTTP (TLS in production), so it's only protected by TLS.
510+
511+**Attack scenario:** A compromised proxy logs the `Authorization` header. The attacker uses the token from the same IP (or any IP if XFF is off) to poll the victim's mailbox and read their (encrypted) messages. The attacker can also send messages as the victim.
512+
513+**Exploitable in practice:** Requires token theft (compromised proxy, log exposure, or MITM without TLS). The E2E encryption means the attacker can't read message content, but they can send messages as the victim (the server doesn't verify the sender's identity on `/messages` — it only uses the bearer token's pubkey for self-send rejection).
514+
515+**Suggested fix:** This is inherent to bearer-token auth. The spec should document that a stolen bearer token grants full impersonation (modulo IP binding) until it expires (1 hour). Consider shorter token lifetimes or per-request signing.
516+
517+### 🟡 Q35. `revokeOthers: true` doesn't revoke challenges — stale challenge can be used to create a new session after revocation
518+
519+**Type:** Implementation gap (server.knot)
520+**Files:** `server.knot:1217-1221`
521+
522+When `revokeOthers: true`, the handler drops all other sessions for the pubkey, but it does *not* drop outstanding challenges for the pubkey. A challenge issued before the `revokeOthers` call is still valid for 60 seconds. An attacker who captured a challenge (e.g., by observing the `/auth/challenge` response) can still complete the `/auth/verify` flow and get a new session — after the `revokeOthers` call.
523+
524+But wait — the `/auth/verify` handler also checks the challenge's IP binding (`matchIp clientIp c.ip`). If the attacker is on a different IP, the verify fails. If they're on the same IP (NAT/proxy), it succeeds. And the new session is *not* revoked by the `revokeOthers` call (it was made after the call).
525+
526+**Attack scenario:**
527+1. Alice requests a challenge (IP 1.2.3.4)
528+2. Attacker captures the challenge response (from a compromised proxy)
529+3. Alice calls `/auth/verify` with `revokeOthers: true` (to log out other devices)
530+4. Attacker uses the captured challenge to call `/auth/verify` from IP 1.2.3.4 (same NAT)
531+5. Attacker gets a new session — not revoked by step 3
532+
533+**Exploitable in practice:** Requires the attacker to be on the same IP as the victim and to have captured the challenge. The 60-second challenge expiry limits the window.
534+
535+**Suggested fix:** When `revokeOthers: true`, also drop outstanding challenges for the pubkey.
536+
537+### 🟡 Q36. Challenge IP binding uses `clientIpOf` which reads XFF — same spoofing surface as session IP binding
538+
539+**Type:** Implementation gap (server.knot)
540+**Files:** `server.knot:1180-1188` (`handleChallenge`), `server.knot:781-784` (`clientIpOf`)
541+
542+The challenge's `ip` is set to `clientIpOf xForwardedFor`, which is the last XFF hop when `trustForwardedFor` is true. If the attacker can spoof XFF (finding 8), they can set any IP on the challenge, and then use the same spoofed IP on the verify — bypassing the IP binding entirely.
543+
544+This is the same XFF spoofing issue already documented (finding 8), but it applies to the challenge IP binding as well. The challenge IP binding is supposed to prevent a challenge obtained by a relay from being used by a different client — but with XFF spoofing, this protection is nullified.
545+
546+**Verdict:** Same as finding 8. Not a new issue, but the challenge IP binding is an additional affected surface.
547+
548+### 🟡 Q37. No rate limit on `/auth/verify` failures — unlimited signature verification attempts
549+
550+**Type:** Design gap (server.knot)
551+**Files:** `server.knot:475` (`authLimit = 60/min`)
552+
553+The `/auth/verify` route shares the `authLimit` (60/min per IP) with `/auth/challenge`. An attacker can attempt 60 signature verifications per minute per IP. Each verification is an Ed25519 signature check, which is relatively expensive (~0.1ms). 60/min = ~0.01 CPU-seconds/min — negligible.
554+
555+But the attacker doesn't need to *succeed* at verification to cause work. Each `/auth/verify` call triggers:
556+1. Hex decode of pubkey (32 bytes) and signature (64 bytes)
557+2. Ed25519 signature verification (expensive: ~0.1ms)
558+3. Linear scan of `*challenges` (up to 10,000 entries)
559+
560+The challenge scan only happens if the signature is valid (short-circuit), so a failed signature still costs the Ed25519 verification. With 60/min per IP and many IPs, the attacker can cause non-trivial CPU load.
561+
562+**Exploitable in practice:** Bounded by rate limits. The Ed25519 verification is fast. Not a practical DoS vector.
563+
564+**Suggested fix:** Consider returning 401 immediately if no matching challenge exists (before verifying the signature), to save the expensive verification when the challenge is invalid. Currently the order is: check challenge exists AND signature valid, with `sigValid` pre-computed. If the challenge doesn't exist, the `any` short-circuits and `sigValid` is never used — but `sigValid` was already computed outside the atomic. So the CPU cost is paid regardless. Move the signature verification inside the atomic block, after the challenge existence check, to short-circuit on missing challenges.
565+
566+Wait, actually looking at the code more carefully:
567+
568+```knot
569+let sigValid = case bytesFromHex pubkey of ... -- computed OUTSIDE atomic
570+result <- atomic (do
571+ ...
572+ let valid = any (\\c -> same c && ...) ch && sigValid -- sigValid used here
573+```
574+
575+`sigValid` is pre-computed, so the Ed25519 verification happens on every `/auth/verify` call regardless of whether a matching challenge exists. This is a CPU waste. If the challenge doesn't exist, the `any` returns false and `sigValid` is irrelevant — but the CPU was already spent.
576+
577+**Suggested fix:** Move the signature verification inside the atomic block, after the challenge existence check. Or compute it lazily (only if the challenge exists).
578+
579+### 🟡 Q38. `handleVerifyAuth` logs session eviction count but not the attacker — no audit trail for Sybil floods
580+
581+**Type:** Implementation gap (server.knot)
582+**Files:** `server.knot:1240`
583+
584+The handler logs `logWarn ("session pool full: evicted N session(s)...")` but doesn't log the pubkey or IP of the request that caused the eviction. An operator investigating a Sybil flood would see warnings but not know which pubkeys or IPs are responsible. This makes it hard to block the attacker.
585+
586+**Suggested fix:** Include the requesting pubkey and IP in the log message.
587+
588+---
589+
590+## 8. Spec Gaps
591+
592+### 🟡 Q39. Spec doesn't specify behavior for empty `encryptedBlob`
593+
594+**Type:** Spec gap (PROTOCOL.md §4)
595+**Files:** PROTOCOL.md §4 (Envelope), `server.knot:319-320` (`isValidBlobHex`)
596+
597+The spec says `encryptedBlob` is "Hex-encoded blob from §3" but doesn't say what happens if it's empty or shorter than `minBlobLen`. The server enforces `isValidBlobHex` (min 346 chars = 173 bytes), but the spec doesn't mention this minimum. An independent implementation might accept shorter blobs and store garbage.
598+
599+**Suggested fix:** Add the minimum blob length to PROTOCOL.md §4 or §5.
600+
601+### 🟡 Q40. Spec doesn't specify JSON encoding requirements (UTF-8, key ordering, whitespace)
602+
603+**Type:** Spec gap (PROTOCOL.md §4, §5)
604+**Files:** PROTOCOL.md §4, §5
605+
606+The spec shows JSON examples but doesn't specify:
607+- Whether keys must be in a specific order (the server uses `GossipEvent` with `eventType` and `pubkey` — order matters for some JSON parsers)
608+- Whether whitespace is allowed in the JSON (a client could send pretty-printed JSON)
609+- Whether UTF-8 is required (JSON spec allows UTF-16/UTF-32, but the server likely only handles UTF-8)
610+- Whether duplicate keys are allowed (JSON allows them, but behavior is undefined)
611+
612+These could cause implementation divergence. For example, a client that sends `{"encryptedBlob": "...", "to": "..."}` (keys reversed) would work with most JSON parsers but might confuse a strict implementation.
613+
614+**Suggested fix:** Specify that all JSON is UTF-8, keys are unordered, whitespace is allowed, and duplicate keys are undefined behavior (last one wins).
615+
616+### 🟡 Q41. Spec doesn't specify what `cursor: 0` means vs absent cursor
617+
618+**Type:** Spec gap (PROTOCOL.md §6)
619+**Files:** PROTOCOL.md §6 (`POST /poll`)
620+
621+The spec says `cursor` is "the maximum `receivedAt` value the client has already seen (`0` on first poll)." But what if the client sends `cursor: -1` or omits the field? The server's `handlePoll` clamps to `max 0 (...)`, so negative cursors become 0. But the spec doesn't say this.
622+
623+**Suggested fix:** Specify that cursor must be ≥ 0, and that 0 means "first poll" (no messages acked).
624+
625+### 🟡 Q42. Spec doesn't specify `to` field validation requirements
626+
627+**Type:** Spec gap (PROTOCOL.md §4, §6)
628+**Files:** PROTOCOL.md §4 (Envelope), PROTOCOL.md §6 (`POST /messages`)
629+
630+The spec says `to` is "Recipient's Ed25519 public key (64 lowercase hex)" but doesn't say:
631+- Must it be a valid Ed25519 public key (a valid curve point)?
632+- Is the server required to validate it?
633+- What happens if it's the sender's own key? (The server rejects this as `self_send`, but the spec doesn't say the server should check this — it's an implementation detail)
634+
635+**Suggested fix:** Specify that `to` must be 64 lowercase hex chars (the server validates format only), and that the server rejects self-sends with `400 self_send`.
636+
637+### 🟡 Q43. Spec doesn't specify the format of `challenge` in auth
638+
639+**Type:** Spec gap (PROTOCOL.md §6)
640+**Files:** PROTOCOL.md §6 (Authentication)
641+
642+The spec says the challenge is "a random challenge string" but doesn't specify:
643+- Its length or entropy
644+- Its format (hex? base64? raw text?)
645+- Whether it must be unique per challenge request
646+
647+The server uses `genToken 48` (48 hex chars, 192 bits). The client signs it as part of the auth message. If an independent implementation uses a shorter challenge (e.g., 8 chars), it would be brute-forceable.
648+
649+**Suggested fix:** Specify minimum challenge length (e.g., 32 hex chars / 128 bits) and format (lowercase hex).
650+
651+### ⚪ Q44. Spec doesn't specify `revokeOthers` semantics precisely
652+
653+**Type:** Spec gap (PROTOCOL.md §6)
654+**Files:** PROTOCOL.md §6 (Authentication step 3)
655+
656+The spec says `revokeOthers: true` "invalidates every other live session for this pubkey" but doesn't specify:
657+- Whether it also invalidates challenges (it doesn't — see Q35)
658+- Whether the revocation is immediate or eventual (it's immediate within the atomic block)
659+- What happens to in-flight requests using a revoked session (they get 401 on the next request)
660+
661+**Suggested fix:** Clarify that only sessions (not challenges) are revoked, revocation is immediate, and revoked sessions get 401 on their next request.
662+
663+---
664+
665+## 9. Privacy / Metadata Leaks
666+
667+### 🟠 Q45. Blob size reveals message type to the server
668+
669+**Type:** Privacy leak (protocol + design)
670+**Files:** PROTOCOL.md §3 (Length Padding), `core/src/protocol.rs:60-91`
671+
672+The padding buckets (256, 512, 1024, ...) obscure the exact compressed size, but the bucket boundaries still leak information about the message type:
673+- A `delivery.ack` with a single ID is tiny (~50 bytes JSON) → compresses to ~40 bytes → inner buffer ~233 bytes → blob ~233 + 16 (AEAD) = 249 → rounds to 256 bucket
674+- A `text` message with a short body is ~100 bytes JSON → compresses to ~80 bytes → inner buffer ~273 bytes → blob ~289 → rounds to 512 bucket
675+- A `profile` with a 64 KiB photo → compresses poorly (base64 is high-entropy) → inner buffer ~65 KiB → rounds to 65536 bucket
676+
677+So the server can distinguish:
678+- A `delivery.ack` (256 bucket) from a `text` (512 bucket) from a `profile` with photo (65536 bucket)
679+- A short text (512) from a long text (1024+)
680+
681+This is a metadata leak beyond what's documented. The spec says "message length is only ever obscured *upward*" and "blob size still distinguishes a one-word reply from a pasted document" — but it doesn't mention that blob size reveals the *message type* (text vs ack vs profile).
682+
683+**Attack scenario:** A relay observes that Alice sends Bob a 256-bucket blob → it's a delivery.ack (Alice received Bob's message). Then Alice sends a 65536-bucket blob → it's a profile update. The relay learns the *pattern* of communication (who acks whom, who updates their profile) without decrypting anything.
684+
685+**Exploitable in practice:** The bucket boundaries are coarse but sufficient to distinguish message types. This is a real metadata leak for a "metadata-privacy-focused messenger."
686+
687+**Suggested fix:** Use a single minimum padding bucket (e.g., always pad to at least 1024 bytes) to make all small messages indistinguishable. Or add random padding within the bucket to add noise. Or pad all messages to the same size (expensive but maximally private).
688+
689+### 🟡 Q46. `receivedAt` timing reveals sender-recipient correlation to the relay
690+
691+**Type:** Privacy leak (server.knot)
692+**Files:** `server.knot:919-925` (`appendMessage`)
693+
694+The server assigns `receivedAt` at message arrival time. If Alice sends a message to Bob via relay R, R records the arrival time. If Bob polls R shortly after, R records the poll time. The correlation between arrival and poll times reveals that Bob was online and received the message — even though the server doesn't know who sent it.
695+
696+This is inherent to the relay model (the server must see the recipient and timing), and is documented in §10 ("Recipient metadata exposure"). But the spec understates the leak: it says "servers see the recipient pubkey and arrival time" — it doesn't mention that the server can correlate arrival and poll times to infer delivery.
697+
698+**Suggested fix:** Document in §10 that the relay can infer delivery by correlating message arrival with recipient poll times.
699+
700+### 🟡 Q47. Poll cursor reveals the recipient's last-seen position to the relay
701+
702+**Type:** Privacy leak (server.knot)
703+**Files:** `server.knot:432-433` (cursor), `server.knot:1265-1275` (cursor clamping)
704+
705+The client sends its cursor (last-seen `receivedAt`) on every poll. This tells the relay exactly how far behind the recipient is — how many messages they haven't read yet. A relay can use this to:
706+- Determine if the recipient is actively reading (cursor advances each poll) or just polling (cursor stays the same)
707+- Estimate the recipient's reading speed (how fast the cursor advances)
708+- Detect when the recipient switches devices (cursor jumps backward)
709+
710+This is inherent to the long-poll model but not documented in §10.
711+
712+**Suggested fix:** Document in §10 that the cursor reveals the recipient's reading progress to the relay.
713+
714+---
715+
716+## 10. TOFU and Key Management
717+
718+### 🟠 Q48. No key change detection — TOFU silently accepts key changes without warning
719+
720+**Type:** Design gap (protocol + app.rs)
721+**Files:** PROTOCOL.md §2 (Trust Model), `core/src/app.rs:2043-2045` (sender check)
722+
723+The spec says "the first time a client encounters a public key, it is accepted and cached" and "Warn users when a contact's public key changes (TOFU violation)." But the implementation doesn't have a mechanism for key changes — because the protocol has no key rotation (§11 is future work).
724+
725+However, there's a subtler issue: the sender's identity is *inside* the AEAD ciphertext. If an attacker sends a message to Bob encrypted to Bob's key, signing with Mallory's key, Bob's client will:
726+1. Decrypt the blob (it's encrypted to Bob's key, so it decrypts successfully)
727+2. Recover the sender's pubkey (Mallory's)
728+3. Check if Mallory is in Bob's contacts
729+4. If not, auto-create a contact for Mallory and display the message
730+
731+This is by design (anyone can send you a message). But if the attacker can somehow get Bob to add Mallory's key as a contact (e.g., by social engineering), and then later send messages that appear to come from Mallory, there's no mechanism to detect that "Mallory" has changed keys — because the key IS the identity.
732+
733+The real TOFU weakness is: if Bob adds Alice's key, and Alice's key is later compromised, an attacker with Alice's key can send messages that appear to come from Alice. There's no way for Bob to detect this without out-of-band verification. The spec acknowledges this ("TOFU only" in §10), but the implementation doesn't provide a UI for out-of-band verification (comparing @p syllables).
734+
735+**Exploitable in practice:** This is the fundamental TOFU weakness, already acknowledged in the spec. The implementation gap is the lack of a verification UI, but that's a product feature, not a protocol bug.
736+
737+**Suggested fix:** Add a UI for fingerprint verification (showing @p syllables for comparison) and a warning when a new contact's key hasn't been verified.
738+
739+### 🟡 Q49. `Identity::from_secret_bytes` validates the keypair but doesn't check for weak keys
740+
741+**Type:** Implementation gap (crypto.rs)
742+**Files:** `core/src/crypto.rs:150-166`
743+
744+`from_secret_bytes` re-derives the public key from the seed and checks it matches. But it doesn't check for weak Ed25519 keys (e.g., keys where the X25519 derived key is a low-order point). The `x25519` function checks for all-zero shared secrets (low-order point detection), but this only fires during encryption/decryption, not at key load time.
745+
746+If the user's Ed25519 key happens to produce an X25519 public key that is a low-order point, every encryption to them would fail with `InvalidEphemeralKey` (because `x25519(eph_priv, recip_x)` would return all-zero). The user would be unable to receive messages but wouldn't know why.
747+
748+The probability of this is negligible (low-order points are extremely rare on Curve25519), but the code doesn't check for it at key generation time.
749+
750+**Suggested fix:** Check `x25519_public` is not a low-order point at key generation/load time. Report an error if it is.
751+
752+### 🟡 Q50. No mechanism to revoke or invalidate a compromised identity key
753+
754+**Type:** Design gap (PROTOCOL.md §11)
755+**Files:** PROTOCOL.md §11 (Future Extensions — Key Rotation)
756+
757+The spec acknowledges this in §11 (future work): "a user who loses or wants to retire a key has no in-protocol way to say so." There's no key revocation, no key rotation, and no mechanism to tell contacts that a key is compromised.
758+
759+If a user's key is stolen, the attacker can:
760+1. Decrypt all captured messages (no forward secrecy)
761+2. Send messages as the user (no revocation)
762+3. Poll the user's mailbox (if they know the server)
763+4. Receive messages intended for the user (if they redirect presence)
764+
765+The only defense is out-of-band communication ("my key was compromised, don't talk to the old key anymore"), which requires every contact to manually re-add the new key.
766+
767+**Suggested fix:** This is acknowledged as future work. Document the impact more prominently in §10: a stolen identity key is a full takeover with no in-protocol recovery.
768+
769+---
770+
771+## Summary of New Findings
772+
773+| # | Severity | Type | Title |
774+|---|----------|------|-------|
775+| Q1 | 🟡 medium | Design gap | HKDF salt omits sender identity — KCI vector |
776+| Q2 | 🟡 medium | Design gap | No AAD on AEAD — envelope fields unauthenticated |
777+| Q5 | 🟡 medium | Spec gap | CRIME/BREACH surface wider than documented for profiles |
778+| Q7 | 🟡 medium | Perf/DoS | appendMessage dedup O(n) — CPU amplification |
779+| Q8 | 🟠 high | Design gap | maxMessagesPerRecipient eviction destroys genuine mail |
780+| Q9 | 🟡 medium | Impl bug | capForwards evicts by retry count only, not destination health |
781+| Q10 | 🟡 medium | Impl concern | genToken entropy depends on Knot runtime CSPRNG |
782+| Q11 | 🟡 medium | Impl concern | Challenge lookup O(n) — timing varies with pool size |
783+| Q16 | 🟠 high | Impl concern | Cursor clamping allows relay to poison cursor → message loss |
784+| Q17 | 🟡 medium | Impl concern | Ack staleness check affected by page ordering |
785+| Q18 | 🟡 medium | Impl bug | seen_ids dedup misses trimmed messages → replay after 1000 msgs |
786+| Q23 | 🟡 medium | Impl gap | PubkeyHex accepts non-curve-point keys at route boundary |
787+| Q25 | 🟡 medium | Impl concern | Malformed poll JSON silently produces empty page |
788+| Q27 | 🟠 high | Design gap | Gossip amplification: 100 online events = 100 mailbox scans + forwards |
789+| Q28 | 🟡 medium | Design gap | Offline events don't cancel pending forwards |
790+| Q29 | 🟡 medium | Design gap | fromServer not verified against connecting peer's address |
791+| Q30 | 🟠 high | Design gap | /auth/challenge pool floodable — soft DoS via XFF spoofing |
792+| Q31 | 🟡 medium | Design gap | Rate limit counts requests not messages — effective rate much higher |
793+| Q32 | 🟡 medium | Design gap | maxSessions Sybil-floodable (documented in code, not in spec) |
794+| Q33 | 🟡 medium | Design gap | maxPresence fillable with fake keys (documented in code, not in spec) |
795+| Q34 | 🟠 high | Design gap | Session token not bound to pubkey — theft = full impersonation |
796+| Q35 | 🟡 medium | Impl gap | revokeOthers doesn't revoke challenges |
797+| Q37 | 🟡 medium | Design gap | Signature verification CPU wasted on missing challenges |
798+| Q38 | 🟡 medium | Impl gap | No audit trail (pubkey/IP) for Sybil eviction |
799+| Q39 | 🟡 medium | Spec gap | Spec doesn't specify min blob length |
800+| Q40 | 🟡 medium | Spec gap | Spec doesn't specify JSON encoding requirements |
801+| Q41 | 🟡 medium | Spec gap | Spec doesn't specify cursor validation |
802+| Q42 | 🟡 medium | Spec gap | Spec doesn't specify `to` field validation |
803+| Q43 | 🟡 medium | Spec gap | Spec doesn't specify challenge format/entropy |
804+| Q44 | ⚪ info | Spec gap | Spec doesn't specify revokeOthers semantics precisely |
805+| Q45 | 🟠 high | Privacy leak | Blob size reveals message type (ack vs text vs profile) to relay |
806+| Q46 | 🟡 medium | Privacy leak | receivedAt timing reveals delivery correlation |
807+| Q47 | 🟡 medium | Privacy leak | Poll cursor reveals reading progress to relay |
808+| Q48 | 🟠 high | Design gap | No key change detection — TOFU has no verification UI |
809+| Q49 | 🟡 medium | Impl gap | No weak key check at identity load time |
810+| Q50 | 🟡 medium | Design gap | No identity key revocation mechanism (acknowledged future work) |
811+
812+**High-severity findings (7):** Q8, Q16, Q27, Q30, Q34, Q45, Q48
813+**Medium-severity findings (24):** Q1, Q2, Q5, Q7, Q9, Q10, Q11, Q17, Q18, Q23, Q25, Q28, Q29, Q31, Q32, Q33, Q35, Q37, Q38, Q39, Q40, Q41, Q42, Q43, Q46, Q47, Q49, Q50
814+**Info (1):** Q44
815+
816+Note: Several findings marked as "not a bug" during analysis were downgraded to info or omitted from the final table. The table includes only findings that represent genuine new issues.
CLAUDE.mdmode 100644 → 120000+1−54
@@ -1,54 +0,0 @@
1−# CLAUDE.md
2−
3−Decentralized E2E encrypted messenger. No accounts, no home servers — identity is a keypair.
4−
5−## Tech Stack
6−
7−- **Client:** F# / .NET 10 / Fabulous (Elm-style MVU) + Avalonia UI
8−- **Server:** Knot (custom functional language, compiled via Rust toolchain)
9−- **Crypto:** libsodium (Ed25519 + X25519 + XChaCha20-Poly1305), LZ4 plaintext compression
10−- **Persistence:** SQLite — client via Donald + Microsoft.Data.Sqlite; server via the Knot runtime, which transparently persists every `*ref` to SQLite
11−
12−## Project Layout
13−
14−- `client/` — F# cross-platform app (macOS desktop, iOS)
15− - `App.fs` — Top-level state machine (Model / Msg / CmdMsg / update)
16− - `AppTypes.fs` — Shared model/msg/envelope types and helpers
17− - `Constants.fs` — App-wide constants (HKDF info, poll timing, profile/settings IDs)
18− - `Crypto.fs` — Encryption/decryption, key derivation, signing
19− - `ApiClient.fs` — HTTP transport (auth, poll, send)
20− - `Protocol.fs` — Envelope serialize/parse + send orchestration
21− - `Store.fs` — SQLite persistence (contacts, messages, settings, profile, outbox)
22− - `Phonemic.fs` — Urbit @p syllable encoding for human-readable keys
23− - `Buttons.fs`, `Labels.fs`, `TextFields.fs`, `Styles.fs`, `Keyboard.fs` — UI primitives
24− - `ViewSetup.fs`, `ViewConversations.fs`, `ViewChat.fs`, `ViewAddContact.fs`, `ViewSettings.fs`, `ViewEditProfile.fs` — Per-page views
25− - `Platform/Desktop/` — macOS entry point + `.app` bundle wiring
26− - `Platform/iOS/` — iOS entry point, native photo picker, native QR scanner
27−- `server.knot` — Complete server implementation (routes, federation, gossip, presence, retries)
28−- `servers.json` — Public relay list (flat JSON array of URLs)
29−- `install.sh` — Linux deployment (downloads release binary, sets up systemd + Caddy)
30−- `PROTOCOL.md` — Wire protocol specification
31−
32−## Build & Run
33−
34−```bash
35−# macOS desktop
36−cd client && ./run-mac.sh
37−
38−# iOS simulator
39−cd client && ./run-ios.sh
40−
41−# Plain build
42−cd client && dotnet build
43−```
44−
45−## Version Control
46−
47−Use **jj** (jujutsu), not git.
48−
49−## Code Conventions
50−
51−- Formatter: Fantomas (`dotnet tool restore && dotnet fantomas client/`)
52−- F# naming: PascalCase types, camelCase functions/fields
53−- Architecture: Elm-style MVU — all state in `Model`, all effects via `CmdMsg`
54−- No mutable state outside UI framework
@@ -0,0 +1 @@
1+AGENTS.md
Cargo.locknew file+2378
@@ -0,0 +1,2378 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "aead"
7+version = "0.5.2"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
10+dependencies = [
11+ "crypto-common",
12+ "generic-array",
13+]
14+
15+[[package]]
16+name = "aho-corasick"
17+version = "1.1.4"
18+source = "registry+https://github.com/rust-lang/crates.io-index"
19+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
20+dependencies = [
21+ "memchr",
22+]
23+
24+[[package]]
25+name = "allocator-api2"
26+version = "0.2.21"
27+source = "registry+https://github.com/rust-lang/crates.io-index"
28+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
29+
30+[[package]]
31+name = "android_system_properties"
32+version = "0.1.5"
33+source = "registry+https://github.com/rust-lang/crates.io-index"
34+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
35+dependencies = [
36+ "libc",
37+]
38+
39+[[package]]
40+name = "anstream"
41+version = "1.0.0"
42+source = "registry+https://github.com/rust-lang/crates.io-index"
43+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
44+dependencies = [
45+ "anstyle",
46+ "anstyle-parse",
47+ "anstyle-query",
48+ "anstyle-wincon",
49+ "colorchoice",
50+ "is_terminal_polyfill",
51+ "utf8parse",
52+]
53+
54+[[package]]
55+name = "anstyle"
56+version = "1.0.14"
57+source = "registry+https://github.com/rust-lang/crates.io-index"
58+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
59+
60+[[package]]
61+name = "anstyle-parse"
62+version = "1.0.0"
63+source = "registry+https://github.com/rust-lang/crates.io-index"
64+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
65+dependencies = [
66+ "utf8parse",
67+]
68+
69+[[package]]
70+name = "anstyle-query"
71+version = "1.1.5"
72+source = "registry+https://github.com/rust-lang/crates.io-index"
73+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
74+dependencies = [
75+ "windows-sys",
76+]
77+
78+[[package]]
79+name = "anstyle-wincon"
80+version = "3.0.11"
81+source = "registry+https://github.com/rust-lang/crates.io-index"
82+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
83+dependencies = [
84+ "anstyle",
85+ "once_cell_polyfill",
86+ "windows-sys",
87+]
88+
89+[[package]]
90+name = "anyhow"
91+version = "1.0.102"
92+source = "registry+https://github.com/rust-lang/crates.io-index"
93+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
94+
95+[[package]]
96+name = "async-channel"
97+version = "1.9.0"
98+source = "registry+https://github.com/rust-lang/crates.io-index"
99+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
100+dependencies = [
101+ "concurrent-queue",
102+ "event-listener",
103+ "futures-core",
104+]
105+
106+[[package]]
107+name = "async-trait"
108+version = "0.1.89"
109+source = "registry+https://github.com/rust-lang/crates.io-index"
110+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
111+dependencies = [
112+ "proc-macro2",
113+ "quote",
114+ "syn 2.0.118",
115+]
116+
117+[[package]]
118+name = "autocfg"
119+version = "1.5.1"
120+source = "registry+https://github.com/rust-lang/crates.io-index"
121+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
122+
123+[[package]]
124+name = "base64"
125+version = "0.22.1"
126+source = "registry+https://github.com/rust-lang/crates.io-index"
127+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
128+
129+[[package]]
130+name = "base64ct"
131+version = "1.8.3"
132+source = "registry+https://github.com/rust-lang/crates.io-index"
133+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
134+
135+[[package]]
136+name = "bincode"
137+version = "1.3.3"
138+source = "registry+https://github.com/rust-lang/crates.io-index"
139+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
140+dependencies = [
141+ "serde",
142+]
143+
144+[[package]]
145+name = "bitflags"
146+version = "2.13.0"
147+source = "registry+https://github.com/rust-lang/crates.io-index"
148+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
149+
150+[[package]]
151+name = "block-buffer"
152+version = "0.10.4"
153+source = "registry+https://github.com/rust-lang/crates.io-index"
154+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
155+dependencies = [
156+ "generic-array",
157+]
158+
159+[[package]]
160+name = "boltffi"
161+version = "0.26.1"
162+source = "registry+https://github.com/rust-lang/crates.io-index"
163+checksum = "0d616d59232b93cbb4200fe03171e2c894d27dbee441c92649500bfe9fe66468"
164+dependencies = [
165+ "boltffi_core",
166+ "boltffi_macros",
167+]
168+
169+[[package]]
170+name = "boltffi_ast"
171+version = "0.26.1"
172+source = "registry+https://github.com/rust-lang/crates.io-index"
173+checksum = "79f01d34d075e93e96374eda34928558b88a5287b532fe0eb508400c802a2abc"
174+dependencies = [
175+ "serde",
176+]
177+
178+[[package]]
179+name = "boltffi_binding"
180+version = "0.26.1"
181+source = "registry+https://github.com/rust-lang/crates.io-index"
182+checksum = "9efa5fe74d4642e9152f52cc99facb0fc52836458c6ba6779b7eae1c33ab4a92"
183+dependencies = [
184+ "boltffi_ast",
185+ "serde",
186+]
187+
188+[[package]]
189+name = "boltffi_core"
190+version = "0.26.1"
191+source = "registry+https://github.com/rust-lang/crates.io-index"
192+checksum = "06964032c9ae4aa7d395c24cb80985d5e63c392cd6ff20f3ca61bb29a9594191"
193+dependencies = [
194+ "boltffi_macros",
195+ "url",
196+ "uuid",
197+]
198+
199+[[package]]
200+name = "boltffi_ffi_rules"
201+version = "0.26.1"
202+source = "registry+https://github.com/rust-lang/crates.io-index"
203+checksum = "070dd06ea89725dc39eb9f96d1ce6fc6822a805d6fec6c7257971f656772abc1"
204+dependencies = [
205+ "serde",
206+ "serde_json",
207+ "syn 2.0.118",
208+ "walkdir",
209+]
210+
211+[[package]]
212+name = "boltffi_macros"
213+version = "0.26.1"
214+source = "registry+https://github.com/rust-lang/crates.io-index"
215+checksum = "92bbe601ce130101d1b6b71ecd6789da4535e94154efcd5df5945090194f74be"
216+dependencies = [
217+ "boltffi_ast",
218+ "boltffi_binding",
219+ "boltffi_ffi_rules",
220+ "proc-macro2",
221+ "quote",
222+ "syn 2.0.118",
223+]
224+
225+[[package]]
226+name = "bumpalo"
227+version = "3.20.3"
228+source = "registry+https://github.com/rust-lang/crates.io-index"
229+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
230+
231+[[package]]
232+name = "byteorder"
233+version = "1.5.0"
234+source = "registry+https://github.com/rust-lang/crates.io-index"
235+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
236+
237+[[package]]
238+name = "cc"
239+version = "1.2.65"
240+source = "registry+https://github.com/rust-lang/crates.io-index"
241+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
242+dependencies = [
243+ "find-msvc-tools",
244+ "jobserver",
245+ "libc",
246+ "shlex",
247+]
248+
249+[[package]]
250+name = "cfb"
251+version = "0.7.3"
252+source = "registry+https://github.com/rust-lang/crates.io-index"
253+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
254+dependencies = [
255+ "byteorder",
256+ "fnv",
257+ "uuid",
258+]
259+
260+[[package]]
261+name = "cfg-if"
262+version = "1.0.4"
263+source = "registry+https://github.com/rust-lang/crates.io-index"
264+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
265+
266+[[package]]
267+name = "chacha20"
268+version = "0.9.1"
269+source = "registry+https://github.com/rust-lang/crates.io-index"
270+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
271+dependencies = [
272+ "cfg-if",
273+ "cipher",
274+ "cpufeatures",
275+]
276+
277+[[package]]
278+name = "chacha20poly1305"
279+version = "0.10.1"
280+source = "registry+https://github.com/rust-lang/crates.io-index"
281+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
282+dependencies = [
283+ "aead",
284+ "chacha20",
285+ "cipher",
286+ "poly1305",
287+ "zeroize",
288+]
289+
290+[[package]]
291+name = "chrono"
292+version = "0.4.45"
293+source = "registry+https://github.com/rust-lang/crates.io-index"
294+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
295+dependencies = [
296+ "iana-time-zone",
297+ "num-traits",
298+ "windows-link",
299+]
300+
301+[[package]]
302+name = "cipher"
303+version = "0.4.4"
304+source = "registry+https://github.com/rust-lang/crates.io-index"
305+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
306+dependencies = [
307+ "crypto-common",
308+ "inout",
309+ "zeroize",
310+]
311+
312+[[package]]
313+name = "clap"
314+version = "4.6.1"
315+source = "registry+https://github.com/rust-lang/crates.io-index"
316+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
317+dependencies = [
318+ "clap_builder",
319+ "clap_derive",
320+]
321+
322+[[package]]
323+name = "clap_builder"
324+version = "4.6.0"
325+source = "registry+https://github.com/rust-lang/crates.io-index"
326+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
327+dependencies = [
328+ "anstream",
329+ "anstyle",
330+ "clap_lex",
331+ "strsim",
332+]
333+
334+[[package]]
335+name = "clap_derive"
336+version = "4.6.1"
337+source = "registry+https://github.com/rust-lang/crates.io-index"
338+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
339+dependencies = [
340+ "heck",
341+ "proc-macro2",
342+ "quote",
343+ "syn 2.0.118",
344+]
345+
346+[[package]]
347+name = "clap_lex"
348+version = "1.1.0"
349+source = "registry+https://github.com/rust-lang/crates.io-index"
350+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
351+
352+[[package]]
353+name = "colorchoice"
354+version = "1.0.5"
355+source = "registry+https://github.com/rust-lang/crates.io-index"
356+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
357+
358+[[package]]
359+name = "concurrent-queue"
360+version = "2.5.0"
361+source = "registry+https://github.com/rust-lang/crates.io-index"
362+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
363+dependencies = [
364+ "crossbeam-utils",
365+]
366+
367+[[package]]
368+name = "const-fnv1a-hash"
369+version = "1.1.0"
370+source = "registry+https://github.com/rust-lang/crates.io-index"
371+checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
372+
373+[[package]]
374+name = "const-oid"
375+version = "0.9.6"
376+source = "registry+https://github.com/rust-lang/crates.io-index"
377+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
378+
379+[[package]]
380+name = "core-foundation-sys"
381+version = "0.8.7"
382+source = "registry+https://github.com/rust-lang/crates.io-index"
383+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
384+
385+[[package]]
386+name = "cpufeatures"
387+version = "0.2.17"
388+source = "registry+https://github.com/rust-lang/crates.io-index"
389+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
390+dependencies = [
391+ "libc",
392+]
393+
394+[[package]]
395+name = "crossbeam-channel"
396+version = "0.5.15"
397+source = "registry+https://github.com/rust-lang/crates.io-index"
398+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
399+dependencies = [
400+ "crossbeam-utils",
401+]
402+
403+[[package]]
404+name = "crossbeam-utils"
405+version = "0.8.21"
406+source = "registry+https://github.com/rust-lang/crates.io-index"
407+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
408+
409+[[package]]
410+name = "crux_core"
411+version = "0.19.0"
412+source = "registry+https://github.com/rust-lang/crates.io-index"
413+checksum = "159720bb44c1a09e0089459217c8ea6c71a78d868c5bf55d5b41f8781e92af7a"
414+dependencies = [
415+ "anyhow",
416+ "bincode",
417+ "crossbeam-channel",
418+ "crux_macros",
419+ "facet 0.44.5",
420+ "facet_generate",
421+ "futures",
422+ "log",
423+ "serde",
424+ "serde_json",
425+ "slab",
426+ "thiserror",
427+]
428+
429+[[package]]
430+name = "crux_http"
431+version = "0.18.0"
432+source = "registry+https://github.com/rust-lang/crates.io-index"
433+checksum = "ef762ab0e77caf6f0fc22ec06491e83967039fe88f3916ec3c8f83b3c52f7e73"
434+dependencies = [
435+ "anyhow",
436+ "async-trait",
437+ "crux_core",
438+ "derive_builder",
439+ "encoding_rs",
440+ "facet 0.44.5",
441+ "facet-generate-attrs",
442+ "futures-util",
443+ "http-types-red-badger-temporary-fork",
444+ "pin-project-lite",
445+ "serde",
446+ "serde_bytes",
447+ "serde_json",
448+ "serde_qs 1.1.2",
449+ "thiserror",
450+ "url",
451+ "web-sys",
452+]
453+
454+[[package]]
455+name = "crux_kv"
456+version = "0.13.0"
457+source = "registry+https://github.com/rust-lang/crates.io-index"
458+checksum = "9ab7d27f05bfbd679c18c5c45a5c15619adf21cf9895a0436728fd3e6a88d783"
459+dependencies = [
460+ "anyhow",
461+ "crux_core",
462+ "facet 0.44.5",
463+ "serde",
464+ "serde_bytes",
465+ "thiserror",
466+]
467+
468+[[package]]
469+name = "crux_macros"
470+version = "0.10.0"
471+source = "registry+https://github.com/rust-lang/crates.io-index"
472+checksum = "952c0b238735c79e917b908c140c7c5f6f6921924ebee21171203fe5fb5fc3f3"
473+dependencies = [
474+ "darling 0.23.0",
475+ "heck",
476+ "proc-macro-error",
477+ "proc-macro2",
478+ "quote",
479+ "syn 2.0.118",
480+]
481+
482+[[package]]
483+name = "crux_time"
484+version = "0.17.0"
485+source = "registry+https://github.com/rust-lang/crates.io-index"
486+checksum = "a2dda3b405568dc1e0fc7a46cc913e6324e918c66fd7cd19d64cc28759d71a58"
487+dependencies = [
488+ "crux_core",
489+ "facet 0.44.5",
490+ "futures",
491+ "serde",
492+ "thiserror",
493+]
494+
495+[[package]]
496+name = "crypto-common"
497+version = "0.1.7"
498+source = "registry+https://github.com/rust-lang/crates.io-index"
499+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
500+dependencies = [
501+ "generic-array",
502+ "rand_core 0.6.4",
503+ "typenum",
504+]
505+
506+[[package]]
507+name = "curve25519-dalek"
508+version = "4.1.3"
509+source = "registry+https://github.com/rust-lang/crates.io-index"
510+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
511+dependencies = [
512+ "cfg-if",
513+ "cpufeatures",
514+ "curve25519-dalek-derive",
515+ "digest",
516+ "fiat-crypto",
517+ "rustc_version",
518+ "subtle",
519+ "zeroize",
520+]
521+
522+[[package]]
523+name = "curve25519-dalek-derive"
524+version = "0.1.1"
525+source = "registry+https://github.com/rust-lang/crates.io-index"
526+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
527+dependencies = [
528+ "proc-macro2",
529+ "quote",
530+ "syn 2.0.118",
531+]
532+
533+[[package]]
534+name = "darling"
535+version = "0.20.11"
536+source = "registry+https://github.com/rust-lang/crates.io-index"
537+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
538+dependencies = [
539+ "darling_core 0.20.11",
540+ "darling_macro 0.20.11",
541+]
542+
543+[[package]]
544+name = "darling"
545+version = "0.23.0"
546+source = "registry+https://github.com/rust-lang/crates.io-index"
547+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
548+dependencies = [
549+ "darling_core 0.23.0",
550+ "darling_macro 0.23.0",
551+]
552+
553+[[package]]
554+name = "darling_core"
555+version = "0.20.11"
556+source = "registry+https://github.com/rust-lang/crates.io-index"
557+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
558+dependencies = [
559+ "fnv",
560+ "ident_case",
561+ "proc-macro2",
562+ "quote",
563+ "strsim",
564+ "syn 2.0.118",
565+]
566+
567+[[package]]
568+name = "darling_core"
569+version = "0.23.0"
570+source = "registry+https://github.com/rust-lang/crates.io-index"
571+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
572+dependencies = [
573+ "ident_case",
574+ "proc-macro2",
575+ "quote",
576+ "strsim",
577+ "syn 2.0.118",
578+]
579+
580+[[package]]
581+name = "darling_macro"
582+version = "0.20.11"
583+source = "registry+https://github.com/rust-lang/crates.io-index"
584+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
585+dependencies = [
586+ "darling_core 0.20.11",
587+ "quote",
588+ "syn 2.0.118",
589+]
590+
591+[[package]]
592+name = "darling_macro"
593+version = "0.23.0"
594+source = "registry+https://github.com/rust-lang/crates.io-index"
595+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
596+dependencies = [
597+ "darling_core 0.23.0",
598+ "quote",
599+ "syn 2.0.118",
600+]
601+
602+[[package]]
603+name = "der"
604+version = "0.7.10"
605+source = "registry+https://github.com/rust-lang/crates.io-index"
606+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
607+dependencies = [
608+ "const-oid",
609+ "zeroize",
610+]
611+
612+[[package]]
613+name = "derive_builder"
614+version = "0.20.2"
615+source = "registry+https://github.com/rust-lang/crates.io-index"
616+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
617+dependencies = [
618+ "derive_builder_macro",
619+]
620+
621+[[package]]
622+name = "derive_builder_core"
623+version = "0.20.2"
624+source = "registry+https://github.com/rust-lang/crates.io-index"
625+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
626+dependencies = [
627+ "darling 0.20.11",
628+ "proc-macro2",
629+ "quote",
630+ "syn 2.0.118",
631+]
632+
633+[[package]]
634+name = "derive_builder_macro"
635+version = "0.20.2"
636+source = "registry+https://github.com/rust-lang/crates.io-index"
637+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
638+dependencies = [
639+ "derive_builder_core",
640+ "syn 2.0.118",
641+]
642+
643+[[package]]
644+name = "digest"
645+version = "0.10.7"
646+source = "registry+https://github.com/rust-lang/crates.io-index"
647+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
648+dependencies = [
649+ "block-buffer",
650+ "crypto-common",
651+ "subtle",
652+]
653+
654+[[package]]
655+name = "displaydoc"
656+version = "0.2.6"
657+source = "registry+https://github.com/rust-lang/crates.io-index"
658+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
659+dependencies = [
660+ "proc-macro2",
661+ "quote",
662+ "syn 2.0.118",
663+]
664+
665+[[package]]
666+name = "ed25519"
667+version = "2.2.3"
668+source = "registry+https://github.com/rust-lang/crates.io-index"
669+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
670+dependencies = [
671+ "pkcs8",
672+ "signature",
673+]
674+
675+[[package]]
676+name = "ed25519-dalek"
677+version = "2.2.0"
678+source = "registry+https://github.com/rust-lang/crates.io-index"
679+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
680+dependencies = [
681+ "curve25519-dalek",
682+ "ed25519",
683+ "rand_core 0.6.4",
684+ "serde",
685+ "sha2",
686+ "subtle",
687+ "zeroize",
688+]
689+
690+[[package]]
691+name = "encoding_rs"
692+version = "0.8.35"
693+source = "registry+https://github.com/rust-lang/crates.io-index"
694+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
695+dependencies = [
696+ "cfg-if",
697+]
698+
699+[[package]]
700+name = "env_logger"
701+version = "0.10.2"
702+source = "registry+https://github.com/rust-lang/crates.io-index"
703+checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
704+dependencies = [
705+ "humantime",
706+ "is-terminal",
707+ "log",
708+ "regex",
709+ "termcolor",
710+]
711+
712+[[package]]
713+name = "equivalent"
714+version = "1.0.2"
715+source = "registry+https://github.com/rust-lang/crates.io-index"
716+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
717+
718+[[package]]
719+name = "event-listener"
720+version = "2.5.3"
721+source = "registry+https://github.com/rust-lang/crates.io-index"
722+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
723+
724+[[package]]
725+name = "facet"
726+version = "0.31.8"
727+source = "registry+https://github.com/rust-lang/crates.io-index"
728+checksum = "4d643309d7c46d073b6c51e29901ed9bc3d858dc09aa21f5b32b424491e1bbef"
729+dependencies = [
730+ "facet-core 0.31.8",
731+ "facet-macros 0.31.8",
732+ "static_assertions",
733+]
734+
735+[[package]]
736+name = "facet"
737+version = "0.44.5"
738+source = "registry+https://github.com/rust-lang/crates.io-index"
739+checksum = "c78066af2cc259674a54fef24323f6081ae54a5f3a3fb53b995af7a867041c81"
740+dependencies = [
741+ "autocfg",
742+ "facet-core 0.44.4",
743+ "facet-macros 0.44.4",
744+]
745+
746+[[package]]
747+name = "facet-core"
748+version = "0.31.8"
749+source = "registry+https://github.com/rust-lang/crates.io-index"
750+checksum = "ac27076d75388bffb6c5e5118078dcbb46f2090b35256c45372ff1c1910b1aaf"
751+dependencies = [
752+ "bitflags",
753+ "impls",
754+]
755+
756+[[package]]
757+name = "facet-core"
758+version = "0.44.4"
759+source = "registry+https://github.com/rust-lang/crates.io-index"
760+checksum = "04625a816bc27bb757b5f62ec17a228b90c08631f579ee0b36eccd6c426df207"
761+dependencies = [
762+ "autocfg",
763+ "chrono",
764+ "const-fnv1a-hash",
765+ "iddqd",
766+ "impls",
767+]
768+
769+[[package]]
770+name = "facet-generate-attrs"
771+version = "0.17.0"
772+source = "registry+https://github.com/rust-lang/crates.io-index"
773+checksum = "3b430b495d59ffcf07dabe103a796d8d3ba809d12bb25a5960b605883835b3de"
774+dependencies = [
775+ "facet 0.44.5",
776+]
777+
778+[[package]]
779+name = "facet-macro-parse"
780+version = "0.44.4"
781+source = "registry+https://github.com/rust-lang/crates.io-index"
782+checksum = "a288cd230608b4d7e89b307f85be2029f265e27dcdc565a2ae5358d0c4e53f57"
783+dependencies = [
784+ "facet-macro-types",
785+ "proc-macro2",
786+ "quote",
787+]
788+
789+[[package]]
790+name = "facet-macro-types"
791+version = "0.44.4"
792+source = "registry+https://github.com/rust-lang/crates.io-index"
793+checksum = "9c805b7f1ad4bba14e1dba5c67ef810bf9eef2373364fe3916af1d08a5411296"
794+dependencies = [
795+ "proc-macro2",
796+ "quote",
797+ "unsynn",
798+]
799+
800+[[package]]
801+name = "facet-macros"
802+version = "0.31.8"
803+source = "registry+https://github.com/rust-lang/crates.io-index"
804+checksum = "391fcf43b68a76ba4a494710629a7b6fb430345a5623a6a87f7c263bfadb1643"
805+dependencies = [
806+ "facet-core 0.31.8",
807+ "facet-macros-emit",
808+]
809+
810+[[package]]
811+name = "facet-macros"
812+version = "0.44.4"
813+source = "registry+https://github.com/rust-lang/crates.io-index"
814+checksum = "e0cc46a94a2725e82f2c0095bac16665057c24d7f4f11169a7a879dbcba81d8f"
815+dependencies = [
816+ "facet-macros-impl",
817+]
818+
819+[[package]]
820+name = "facet-macros-emit"
821+version = "0.31.8"
822+source = "registry+https://github.com/rust-lang/crates.io-index"
823+checksum = "e81e536ccafa7d5ef71822c9a67a28a0b29fe0170eaf2d134c36854a3d56ee9a"
824+dependencies = [
825+ "facet-macros-parse",
826+ "quote",
827+]
828+
829+[[package]]
830+name = "facet-macros-impl"
831+version = "0.44.4"
832+source = "registry+https://github.com/rust-lang/crates.io-index"
833+checksum = "1af13fd5dfa728cc48418cf33cbcfc0d5cfdbaaf22a3d0f5229da4b1e5669fa5"
834+dependencies = [
835+ "facet-macro-parse",
836+ "facet-macro-types",
837+ "proc-macro2",
838+ "quote",
839+ "strsim",
840+ "unsynn",
841+]
842+
843+[[package]]
844+name = "facet-macros-parse"
845+version = "0.31.8"
846+source = "registry+https://github.com/rust-lang/crates.io-index"
847+checksum = "8da5d4ded1874033bdef5000712d80d28feefe2abb48bb6f31b6fe59b7e9792f"
848+dependencies = [
849+ "proc-macro2",
850+ "quote",
851+ "unsynn",
852+]
853+
854+[[package]]
855+name = "facet_generate"
856+version = "0.17.1"
857+source = "registry+https://github.com/rust-lang/crates.io-index"
858+checksum = "831d4a105b7e0abce52910f544ffa2698064b1d4864d381672137f804548a45a"
859+dependencies = [
860+ "derive_builder",
861+ "facet 0.44.5",
862+ "facet-generate-attrs",
863+ "heck",
864+ "include_dir",
865+ "indent",
866+ "indoc",
867+ "regex",
868+ "serde",
869+ "serde_json",
870+ "textwrap",
871+ "thiserror",
872+]
873+
874+[[package]]
875+name = "fastrand"
876+version = "2.4.1"
877+source = "registry+https://github.com/rust-lang/crates.io-index"
878+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
879+
880+[[package]]
881+name = "fiat-crypto"
882+version = "0.2.9"
883+source = "registry+https://github.com/rust-lang/crates.io-index"
884+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
885+
886+[[package]]
887+name = "find-msvc-tools"
888+version = "0.1.9"
889+source = "registry+https://github.com/rust-lang/crates.io-index"
890+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
891+
892+[[package]]
893+name = "fnv"
894+version = "1.0.7"
895+source = "registry+https://github.com/rust-lang/crates.io-index"
896+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
897+
898+[[package]]
899+name = "foldhash"
900+version = "0.2.0"
901+source = "registry+https://github.com/rust-lang/crates.io-index"
902+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
903+
904+[[package]]
905+name = "form_urlencoded"
906+version = "1.2.2"
907+source = "registry+https://github.com/rust-lang/crates.io-index"
908+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
909+dependencies = [
910+ "percent-encoding",
911+]
912+
913+[[package]]
914+name = "futures"
915+version = "0.3.32"
916+source = "registry+https://github.com/rust-lang/crates.io-index"
917+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
918+dependencies = [
919+ "futures-channel",
920+ "futures-core",
921+ "futures-executor",
922+ "futures-io",
923+ "futures-sink",
924+ "futures-task",
925+ "futures-util",
926+]
927+
928+[[package]]
929+name = "futures-channel"
930+version = "0.3.32"
931+source = "registry+https://github.com/rust-lang/crates.io-index"
932+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
933+dependencies = [
934+ "futures-core",
935+ "futures-sink",
936+]
937+
938+[[package]]
939+name = "futures-core"
940+version = "0.3.32"
941+source = "registry+https://github.com/rust-lang/crates.io-index"
942+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
943+
944+[[package]]
945+name = "futures-executor"
946+version = "0.3.32"
947+source = "registry+https://github.com/rust-lang/crates.io-index"
948+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
949+dependencies = [
950+ "futures-core",
951+ "futures-task",
952+ "futures-util",
953+]
954+
955+[[package]]
956+name = "futures-io"
957+version = "0.3.32"
958+source = "registry+https://github.com/rust-lang/crates.io-index"
959+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
960+
961+[[package]]
962+name = "futures-lite"
963+version = "2.6.1"
964+source = "registry+https://github.com/rust-lang/crates.io-index"
965+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
966+dependencies = [
967+ "fastrand",
968+ "futures-core",
969+ "futures-io",
970+ "parking",
971+ "pin-project-lite",
972+]
973+
974+[[package]]
975+name = "futures-macro"
976+version = "0.3.32"
977+source = "registry+https://github.com/rust-lang/crates.io-index"
978+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
979+dependencies = [
980+ "proc-macro2",
981+ "quote",
982+ "syn 2.0.118",
983+]
984+
985+[[package]]
986+name = "futures-sink"
987+version = "0.3.32"
988+source = "registry+https://github.com/rust-lang/crates.io-index"
989+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
990+
991+[[package]]
992+name = "futures-task"
993+version = "0.3.32"
994+source = "registry+https://github.com/rust-lang/crates.io-index"
995+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
996+
997+[[package]]
998+name = "futures-util"
999+version = "0.3.32"
1000+source = "registry+https://github.com/rust-lang/crates.io-index"
1001+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
1002+dependencies = [
1003+ "futures-channel",
1004+ "futures-core",
1005+ "futures-io",
1006+ "futures-macro",
1007+ "futures-sink",
1008+ "futures-task",
1009+ "memchr",
1010+ "pin-project-lite",
1011+ "slab",
1012+]
1013+
1014+[[package]]
1015+name = "generic-array"
1016+version = "0.14.7"
1017+source = "registry+https://github.com/rust-lang/crates.io-index"
1018+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1019+dependencies = [
1020+ "typenum",
1021+ "version_check",
1022+]
1023+
1024+[[package]]
1025+name = "getrandom"
1026+version = "0.2.17"
1027+source = "registry+https://github.com/rust-lang/crates.io-index"
1028+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1029+dependencies = [
1030+ "cfg-if",
1031+ "libc",
1032+ "wasi",
1033+]
1034+
1035+[[package]]
1036+name = "getrandom"
1037+version = "0.3.4"
1038+source = "registry+https://github.com/rust-lang/crates.io-index"
1039+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1040+dependencies = [
1041+ "cfg-if",
1042+ "libc",
1043+ "r-efi 5.3.0",
1044+ "wasip2",
1045+]
1046+
1047+[[package]]
1048+name = "getrandom"
1049+version = "0.4.3"
1050+source = "registry+https://github.com/rust-lang/crates.io-index"
1051+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1052+dependencies = [
1053+ "cfg-if",
1054+ "libc",
1055+ "r-efi 6.0.0",
1056+]
1057+
1058+[[package]]
1059+name = "hashbrown"
1060+version = "0.16.1"
1061+source = "registry+https://github.com/rust-lang/crates.io-index"
1062+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1063+dependencies = [
1064+ "allocator-api2",
1065+]
1066+
1067+[[package]]
1068+name = "heck"
1069+version = "0.5.0"
1070+source = "registry+https://github.com/rust-lang/crates.io-index"
1071+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1072+
1073+[[package]]
1074+name = "hermit-abi"
1075+version = "0.5.2"
1076+source = "registry+https://github.com/rust-lang/crates.io-index"
1077+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
1078+
1079+[[package]]
1080+name = "hex"
1081+version = "0.4.3"
1082+source = "registry+https://github.com/rust-lang/crates.io-index"
1083+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1084+
1085+[[package]]
1086+name = "hkdf"
1087+version = "0.12.4"
1088+source = "registry+https://github.com/rust-lang/crates.io-index"
1089+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
1090+dependencies = [
1091+ "hmac",
1092+]
1093+
1094+[[package]]
1095+name = "hmac"
1096+version = "0.12.1"
1097+source = "registry+https://github.com/rust-lang/crates.io-index"
1098+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1099+dependencies = [
1100+ "digest",
1101+]
1102+
1103+[[package]]
1104+name = "http-types-red-badger-temporary-fork"
1105+version = "6.0.0"
1106+source = "registry+https://github.com/rust-lang/crates.io-index"
1107+checksum = "3042c4259fc659f9fa8aea1d71c4ae4201c58a0adddd3406910aed0870c68382"
1108+dependencies = [
1109+ "anyhow",
1110+ "async-channel",
1111+ "base64",
1112+ "facet 0.31.8",
1113+ "futures-lite",
1114+ "infer",
1115+ "pin-project-lite",
1116+ "rand",
1117+ "serde",
1118+ "serde_json",
1119+ "serde_qs 0.15.0",
1120+ "serde_urlencoded",
1121+ "url",
1122+]
1123+
1124+[[package]]
1125+name = "humantime"
1126+version = "2.3.0"
1127+source = "registry+https://github.com/rust-lang/crates.io-index"
1128+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
1129+
1130+[[package]]
1131+name = "iana-time-zone"
1132+version = "0.1.65"
1133+source = "registry+https://github.com/rust-lang/crates.io-index"
1134+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1135+dependencies = [
1136+ "android_system_properties",
1137+ "core-foundation-sys",
1138+ "iana-time-zone-haiku",
1139+ "js-sys",
1140+ "log",
1141+ "wasm-bindgen",
1142+ "windows-core",
1143+]
1144+
1145+[[package]]
1146+name = "iana-time-zone-haiku"
1147+version = "0.1.2"
1148+source = "registry+https://github.com/rust-lang/crates.io-index"
1149+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1150+dependencies = [
1151+ "cc",
1152+]
1153+
1154+[[package]]
1155+name = "icu_collections"
1156+version = "2.2.0"
1157+source = "registry+https://github.com/rust-lang/crates.io-index"
1158+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
1159+dependencies = [
1160+ "displaydoc",
1161+ "potential_utf",
1162+ "utf8_iter",
1163+ "yoke",
1164+ "zerofrom",
1165+ "zerovec",
1166+]
1167+
1168+[[package]]
1169+name = "icu_locale_core"
1170+version = "2.2.0"
1171+source = "registry+https://github.com/rust-lang/crates.io-index"
1172+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
1173+dependencies = [
1174+ "displaydoc",
1175+ "litemap",
1176+ "tinystr",
1177+ "writeable",
1178+ "zerovec",
1179+]
1180+
1181+[[package]]
1182+name = "icu_normalizer"
1183+version = "2.2.0"
1184+source = "registry+https://github.com/rust-lang/crates.io-index"
1185+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
1186+dependencies = [
1187+ "icu_collections",
1188+ "icu_normalizer_data",
1189+ "icu_properties",
1190+ "icu_provider",
1191+ "smallvec",
1192+ "zerovec",
1193+]
1194+
1195+[[package]]
1196+name = "icu_normalizer_data"
1197+version = "2.2.0"
1198+source = "registry+https://github.com/rust-lang/crates.io-index"
1199+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
1200+
1201+[[package]]
1202+name = "icu_properties"
1203+version = "2.2.0"
1204+source = "registry+https://github.com/rust-lang/crates.io-index"
1205+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
1206+dependencies = [
1207+ "icu_collections",
1208+ "icu_locale_core",
1209+ "icu_properties_data",
1210+ "icu_provider",
1211+ "zerotrie",
1212+ "zerovec",
1213+]
1214+
1215+[[package]]
1216+name = "icu_properties_data"
1217+version = "2.2.0"
1218+source = "registry+https://github.com/rust-lang/crates.io-index"
1219+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
1220+
1221+[[package]]
1222+name = "icu_provider"
1223+version = "2.2.0"
1224+source = "registry+https://github.com/rust-lang/crates.io-index"
1225+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
1226+dependencies = [
1227+ "displaydoc",
1228+ "icu_locale_core",
1229+ "writeable",
1230+ "yoke",
1231+ "zerofrom",
1232+ "zerotrie",
1233+ "zerovec",
1234+]
1235+
1236+[[package]]
1237+name = "iddqd"
1238+version = "0.3.18"
1239+source = "registry+https://github.com/rust-lang/crates.io-index"
1240+checksum = "616230c7d641ef971a3a5bfcc654c6b7524ab9c9fb665693c6a397dba9a14aca"
1241+dependencies = [
1242+ "allocator-api2",
1243+ "equivalent",
1244+ "foldhash",
1245+ "hashbrown",
1246+ "rustc-hash",
1247+]
1248+
1249+[[package]]
1250+name = "ident_case"
1251+version = "1.0.1"
1252+source = "registry+https://github.com/rust-lang/crates.io-index"
1253+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1254+
1255+[[package]]
1256+name = "idna"
1257+version = "1.1.0"
1258+source = "registry+https://github.com/rust-lang/crates.io-index"
1259+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1260+dependencies = [
1261+ "idna_adapter",
1262+ "smallvec",
1263+ "utf8_iter",
1264+]
1265+
1266+[[package]]
1267+name = "idna_adapter"
1268+version = "1.2.2"
1269+source = "registry+https://github.com/rust-lang/crates.io-index"
1270+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
1271+dependencies = [
1272+ "icu_normalizer",
1273+ "icu_properties",
1274+]
1275+
1276+[[package]]
1277+name = "impls"
1278+version = "1.0.3"
1279+source = "registry+https://github.com/rust-lang/crates.io-index"
1280+checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc"
1281+
1282+[[package]]
1283+name = "include_dir"
1284+version = "0.7.4"
1285+source = "registry+https://github.com/rust-lang/crates.io-index"
1286+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
1287+dependencies = [
1288+ "include_dir_macros",
1289+]
1290+
1291+[[package]]
1292+name = "include_dir_macros"
1293+version = "0.7.4"
1294+source = "registry+https://github.com/rust-lang/crates.io-index"
1295+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
1296+dependencies = [
1297+ "proc-macro2",
1298+ "quote",
1299+]
1300+
1301+[[package]]
1302+name = "indent"
1303+version = "0.1.1"
1304+source = "registry+https://github.com/rust-lang/crates.io-index"
1305+checksum = "d9f1a0777d972970f204fdf8ef319f1f4f8459131636d7e3c96c5d59570d0fa6"
1306+
1307+[[package]]
1308+name = "indoc"
1309+version = "2.0.7"
1310+source = "registry+https://github.com/rust-lang/crates.io-index"
1311+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
1312+dependencies = [
1313+ "rustversion",
1314+]
1315+
1316+[[package]]
1317+name = "infer"
1318+version = "0.19.0"
1319+source = "registry+https://github.com/rust-lang/crates.io-index"
1320+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
1321+dependencies = [
1322+ "cfb",
1323+]
1324+
1325+[[package]]
1326+name = "inout"
1327+version = "0.1.4"
1328+source = "registry+https://github.com/rust-lang/crates.io-index"
1329+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1330+dependencies = [
1331+ "generic-array",
1332+]
1333+
1334+[[package]]
1335+name = "is-terminal"
1336+version = "0.4.17"
1337+source = "registry+https://github.com/rust-lang/crates.io-index"
1338+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
1339+dependencies = [
1340+ "hermit-abi",
1341+ "libc",
1342+ "windows-sys",
1343+]
1344+
1345+[[package]]
1346+name = "is_terminal_polyfill"
1347+version = "1.70.2"
1348+source = "registry+https://github.com/rust-lang/crates.io-index"
1349+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1350+
1351+[[package]]
1352+name = "itoa"
1353+version = "1.0.18"
1354+source = "registry+https://github.com/rust-lang/crates.io-index"
1355+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1356+
1357+[[package]]
1358+name = "jobserver"
1359+version = "0.1.34"
1360+source = "registry+https://github.com/rust-lang/crates.io-index"
1361+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
1362+dependencies = [
1363+ "getrandom 0.3.4",
1364+ "libc",
1365+]
1366+
1367+[[package]]
1368+name = "js-sys"
1369+version = "0.3.103"
1370+source = "registry+https://github.com/rust-lang/crates.io-index"
1371+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
1372+dependencies = [
1373+ "cfg-if",
1374+ "futures-util",
1375+ "wasm-bindgen",
1376+]
1377+
1378+[[package]]
1379+name = "libc"
1380+version = "0.2.186"
1381+source = "registry+https://github.com/rust-lang/crates.io-index"
1382+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
1383+
1384+[[package]]
1385+name = "litemap"
1386+version = "0.8.2"
1387+source = "registry+https://github.com/rust-lang/crates.io-index"
1388+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
1389+
1390+[[package]]
1391+name = "log"
1392+version = "0.4.33"
1393+source = "registry+https://github.com/rust-lang/crates.io-index"
1394+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
1395+
1396+[[package]]
1397+name = "memchr"
1398+version = "2.8.2"
1399+source = "registry+https://github.com/rust-lang/crates.io-index"
1400+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
1401+
1402+[[package]]
1403+name = "mutants"
1404+version = "0.0.3"
1405+source = "registry+https://github.com/rust-lang/crates.io-index"
1406+checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126"
1407+
1408+[[package]]
1409+name = "num-traits"
1410+version = "0.2.19"
1411+source = "registry+https://github.com/rust-lang/crates.io-index"
1412+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1413+dependencies = [
1414+ "autocfg",
1415+]
1416+
1417+[[package]]
1418+name = "once_cell"
1419+version = "1.21.4"
1420+source = "registry+https://github.com/rust-lang/crates.io-index"
1421+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1422+
1423+[[package]]
1424+name = "once_cell_polyfill"
1425+version = "1.70.2"
1426+source = "registry+https://github.com/rust-lang/crates.io-index"
1427+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1428+
1429+[[package]]
1430+name = "opaque-debug"
1431+version = "0.3.1"
1432+source = "registry+https://github.com/rust-lang/crates.io-index"
1433+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
1434+
1435+[[package]]
1436+name = "parking"
1437+version = "2.2.1"
1438+source = "registry+https://github.com/rust-lang/crates.io-index"
1439+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1440+
1441+[[package]]
1442+name = "percent-encoding"
1443+version = "2.3.2"
1444+source = "registry+https://github.com/rust-lang/crates.io-index"
1445+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1446+
1447+[[package]]
1448+name = "pin-project-lite"
1449+version = "0.2.17"
1450+source = "registry+https://github.com/rust-lang/crates.io-index"
1451+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1452+
1453+[[package]]
1454+name = "pkcs8"
1455+version = "0.10.2"
1456+source = "registry+https://github.com/rust-lang/crates.io-index"
1457+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
1458+dependencies = [
1459+ "der",
1460+ "spki",
1461+]
1462+
1463+[[package]]
1464+name = "pkg-config"
1465+version = "0.3.33"
1466+source = "registry+https://github.com/rust-lang/crates.io-index"
1467+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
1468+
1469+[[package]]
1470+name = "poly1305"
1471+version = "0.8.0"
1472+source = "registry+https://github.com/rust-lang/crates.io-index"
1473+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
1474+dependencies = [
1475+ "cpufeatures",
1476+ "opaque-debug",
1477+ "universal-hash",
1478+]
1479+
1480+[[package]]
1481+name = "potential_utf"
1482+version = "0.1.5"
1483+source = "registry+https://github.com/rust-lang/crates.io-index"
1484+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
1485+dependencies = [
1486+ "zerovec",
1487+]
1488+
1489+[[package]]
1490+name = "ppv-lite86"
1491+version = "0.2.21"
1492+source = "registry+https://github.com/rust-lang/crates.io-index"
1493+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1494+dependencies = [
1495+ "zerocopy",
1496+]
1497+
1498+[[package]]
1499+name = "pretty_env_logger"
1500+version = "0.5.0"
1501+source = "registry+https://github.com/rust-lang/crates.io-index"
1502+checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c"
1503+dependencies = [
1504+ "env_logger",
1505+ "log",
1506+]
1507+
1508+[[package]]
1509+name = "proc-macro-error"
1510+version = "1.0.4"
1511+source = "registry+https://github.com/rust-lang/crates.io-index"
1512+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
1513+dependencies = [
1514+ "proc-macro-error-attr",
1515+ "proc-macro2",
1516+ "quote",
1517+ "syn 1.0.109",
1518+ "version_check",
1519+]
1520+
1521+[[package]]
1522+name = "proc-macro-error-attr"
1523+version = "1.0.4"
1524+source = "registry+https://github.com/rust-lang/crates.io-index"
1525+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
1526+dependencies = [
1527+ "proc-macro2",
1528+ "quote",
1529+ "version_check",
1530+]
1531+
1532+[[package]]
1533+name = "proc-macro2"
1534+version = "1.0.106"
1535+source = "registry+https://github.com/rust-lang/crates.io-index"
1536+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
1537+dependencies = [
1538+ "unicode-ident",
1539+]
1540+
1541+[[package]]
1542+name = "quote"
1543+version = "1.0.46"
1544+source = "registry+https://github.com/rust-lang/crates.io-index"
1545+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
1546+dependencies = [
1547+ "proc-macro2",
1548+]
1549+
1550+[[package]]
1551+name = "r-efi"
1552+version = "5.3.0"
1553+source = "registry+https://github.com/rust-lang/crates.io-index"
1554+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1555+
1556+[[package]]
1557+name = "r-efi"
1558+version = "6.0.0"
1559+source = "registry+https://github.com/rust-lang/crates.io-index"
1560+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1561+
1562+[[package]]
1563+name = "rand"
1564+version = "0.9.4"
1565+source = "registry+https://github.com/rust-lang/crates.io-index"
1566+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
1567+dependencies = [
1568+ "rand_chacha 0.9.0",
1569+ "rand_core 0.9.5",
1570+]
1571+
1572+[[package]]
1573+name = "rand_chacha"
1574+version = "0.3.1"
1575+source = "registry+https://github.com/rust-lang/crates.io-index"
1576+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1577+dependencies = [
1578+ "ppv-lite86",
1579+ "rand_core 0.6.4",
1580+]
1581+
1582+[[package]]
1583+name = "rand_chacha"
1584+version = "0.9.0"
1585+source = "registry+https://github.com/rust-lang/crates.io-index"
1586+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1587+dependencies = [
1588+ "ppv-lite86",
1589+ "rand_core 0.9.5",
1590+]
1591+
1592+[[package]]
1593+name = "rand_core"
1594+version = "0.6.4"
1595+source = "registry+https://github.com/rust-lang/crates.io-index"
1596+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1597+dependencies = [
1598+ "getrandom 0.2.17",
1599+]
1600+
1601+[[package]]
1602+name = "rand_core"
1603+version = "0.9.5"
1604+source = "registry+https://github.com/rust-lang/crates.io-index"
1605+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1606+dependencies = [
1607+ "getrandom 0.3.4",
1608+]
1609+
1610+[[package]]
1611+name = "regex"
1612+version = "1.12.4"
1613+source = "registry+https://github.com/rust-lang/crates.io-index"
1614+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
1615+dependencies = [
1616+ "aho-corasick",
1617+ "memchr",
1618+ "regex-automata",
1619+ "regex-syntax",
1620+]
1621+
1622+[[package]]
1623+name = "regex-automata"
1624+version = "0.4.14"
1625+source = "registry+https://github.com/rust-lang/crates.io-index"
1626+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1627+dependencies = [
1628+ "aho-corasick",
1629+ "memchr",
1630+ "regex-syntax",
1631+]
1632+
1633+[[package]]
1634+name = "regex-syntax"
1635+version = "0.8.11"
1636+source = "registry+https://github.com/rust-lang/crates.io-index"
1637+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1638+
1639+[[package]]
1640+name = "rustc-hash"
1641+version = "2.1.2"
1642+source = "registry+https://github.com/rust-lang/crates.io-index"
1643+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
1644+
1645+[[package]]
1646+name = "rustc_version"
1647+version = "0.4.1"
1648+source = "registry+https://github.com/rust-lang/crates.io-index"
1649+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1650+dependencies = [
1651+ "semver",
1652+]
1653+
1654+[[package]]
1655+name = "rustversion"
1656+version = "1.0.22"
1657+source = "registry+https://github.com/rust-lang/crates.io-index"
1658+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1659+
1660+[[package]]
1661+name = "ryu"
1662+version = "1.0.23"
1663+source = "registry+https://github.com/rust-lang/crates.io-index"
1664+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1665+
1666+[[package]]
1667+name = "same-file"
1668+version = "1.0.6"
1669+source = "registry+https://github.com/rust-lang/crates.io-index"
1670+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1671+dependencies = [
1672+ "winapi-util",
1673+]
1674+
1675+[[package]]
1676+name = "semver"
1677+version = "1.0.28"
1678+source = "registry+https://github.com/rust-lang/crates.io-index"
1679+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1680+
1681+[[package]]
1682+name = "serde"
1683+version = "1.0.228"
1684+source = "registry+https://github.com/rust-lang/crates.io-index"
1685+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1686+dependencies = [
1687+ "serde_core",
1688+ "serde_derive",
1689+]
1690+
1691+[[package]]
1692+name = "serde_bytes"
1693+version = "0.11.19"
1694+source = "registry+https://github.com/rust-lang/crates.io-index"
1695+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
1696+dependencies = [
1697+ "serde",
1698+ "serde_core",
1699+]
1700+
1701+[[package]]
1702+name = "serde_core"
1703+version = "1.0.228"
1704+source = "registry+https://github.com/rust-lang/crates.io-index"
1705+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1706+dependencies = [
1707+ "serde_derive",
1708+]
1709+
1710+[[package]]
1711+name = "serde_derive"
1712+version = "1.0.228"
1713+source = "registry+https://github.com/rust-lang/crates.io-index"
1714+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1715+dependencies = [
1716+ "proc-macro2",
1717+ "quote",
1718+ "syn 2.0.118",
1719+]
1720+
1721+[[package]]
1722+name = "serde_json"
1723+version = "1.0.150"
1724+source = "registry+https://github.com/rust-lang/crates.io-index"
1725+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
1726+dependencies = [
1727+ "itoa",
1728+ "memchr",
1729+ "serde",
1730+ "serde_core",
1731+ "zmij",
1732+]
1733+
1734+[[package]]
1735+name = "serde_qs"
1736+version = "0.15.0"
1737+source = "registry+https://github.com/rust-lang/crates.io-index"
1738+checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
1739+dependencies = [
1740+ "percent-encoding",
1741+ "serde",
1742+ "thiserror",
1743+]
1744+
1745+[[package]]
1746+name = "serde_qs"
1747+version = "1.1.2"
1748+source = "registry+https://github.com/rust-lang/crates.io-index"
1749+checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332"
1750+dependencies = [
1751+ "itoa",
1752+ "percent-encoding",
1753+ "ryu",
1754+ "serde",
1755+]
1756+
1757+[[package]]
1758+name = "serde_urlencoded"
1759+version = "0.7.1"
1760+source = "registry+https://github.com/rust-lang/crates.io-index"
1761+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1762+dependencies = [
1763+ "form_urlencoded",
1764+ "itoa",
1765+ "ryu",
1766+ "serde",
1767+]
1768+
1769+[[package]]
1770+name = "sha2"
1771+version = "0.10.9"
1772+source = "registry+https://github.com/rust-lang/crates.io-index"
1773+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1774+dependencies = [
1775+ "cfg-if",
1776+ "cpufeatures",
1777+ "digest",
1778+]
1779+
1780+[[package]]
1781+name = "shlex"
1782+version = "2.0.1"
1783+source = "registry+https://github.com/rust-lang/crates.io-index"
1784+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1785+
1786+[[package]]
1787+name = "signature"
1788+version = "2.2.0"
1789+source = "registry+https://github.com/rust-lang/crates.io-index"
1790+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
1791+dependencies = [
1792+ "rand_core 0.6.4",
1793+]
1794+
1795+[[package]]
1796+name = "skrepka_core"
1797+version = "0.1.0"
1798+dependencies = [
1799+ "anyhow",
1800+ "base64",
1801+ "boltffi",
1802+ "chacha20poly1305",
1803+ "clap",
1804+ "crux_core",
1805+ "crux_http",
1806+ "crux_kv",
1807+ "crux_time",
1808+ "curve25519-dalek",
1809+ "ed25519-dalek",
1810+ "facet 0.44.5",
1811+ "hex",
1812+ "hkdf",
1813+ "log",
1814+ "pretty_env_logger",
1815+ "rand_chacha 0.3.1",
1816+ "rand_core 0.6.4",
1817+ "serde",
1818+ "serde_json",
1819+ "sha2",
1820+ "url",
1821+ "uuid",
1822+ "x25519-dalek",
1823+ "zeroize",
1824+ "zstd",
1825+]
1826+
1827+[[package]]
1828+name = "slab"
1829+version = "0.4.12"
1830+source = "registry+https://github.com/rust-lang/crates.io-index"
1831+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1832+
1833+[[package]]
1834+name = "smallvec"
1835+version = "1.15.2"
1836+source = "registry+https://github.com/rust-lang/crates.io-index"
1837+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1838+
1839+[[package]]
1840+name = "smawk"
1841+version = "0.3.3"
1842+source = "registry+https://github.com/rust-lang/crates.io-index"
1843+checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
1844+
1845+[[package]]
1846+name = "spki"
1847+version = "0.7.3"
1848+source = "registry+https://github.com/rust-lang/crates.io-index"
1849+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
1850+dependencies = [
1851+ "base64ct",
1852+ "der",
1853+]
1854+
1855+[[package]]
1856+name = "stable_deref_trait"
1857+version = "1.2.1"
1858+source = "registry+https://github.com/rust-lang/crates.io-index"
1859+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1860+
1861+[[package]]
1862+name = "static_assertions"
1863+version = "1.1.0"
1864+source = "registry+https://github.com/rust-lang/crates.io-index"
1865+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
1866+
1867+[[package]]
1868+name = "strsim"
1869+version = "0.11.1"
1870+source = "registry+https://github.com/rust-lang/crates.io-index"
1871+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1872+
1873+[[package]]
1874+name = "subtle"
1875+version = "2.6.1"
1876+source = "registry+https://github.com/rust-lang/crates.io-index"
1877+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1878+
1879+[[package]]
1880+name = "syn"
1881+version = "1.0.109"
1882+source = "registry+https://github.com/rust-lang/crates.io-index"
1883+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
1884+dependencies = [
1885+ "proc-macro2",
1886+ "unicode-ident",
1887+]
1888+
1889+[[package]]
1890+name = "syn"
1891+version = "2.0.118"
1892+source = "registry+https://github.com/rust-lang/crates.io-index"
1893+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
1894+dependencies = [
1895+ "proc-macro2",
1896+ "quote",
1897+ "unicode-ident",
1898+]
1899+
1900+[[package]]
1901+name = "synstructure"
1902+version = "0.13.2"
1903+source = "registry+https://github.com/rust-lang/crates.io-index"
1904+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1905+dependencies = [
1906+ "proc-macro2",
1907+ "quote",
1908+ "syn 2.0.118",
1909+]
1910+
1911+[[package]]
1912+name = "termcolor"
1913+version = "1.4.1"
1914+source = "registry+https://github.com/rust-lang/crates.io-index"
1915+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
1916+dependencies = [
1917+ "winapi-util",
1918+]
1919+
1920+[[package]]
1921+name = "textwrap"
1922+version = "0.16.2"
1923+source = "registry+https://github.com/rust-lang/crates.io-index"
1924+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
1925+dependencies = [
1926+ "smawk",
1927+ "unicode-linebreak",
1928+ "unicode-width",
1929+]
1930+
1931+[[package]]
1932+name = "thiserror"
1933+version = "2.0.18"
1934+source = "registry+https://github.com/rust-lang/crates.io-index"
1935+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1936+dependencies = [
1937+ "thiserror-impl",
1938+]
1939+
1940+[[package]]
1941+name = "thiserror-impl"
1942+version = "2.0.18"
1943+source = "registry+https://github.com/rust-lang/crates.io-index"
1944+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1945+dependencies = [
1946+ "proc-macro2",
1947+ "quote",
1948+ "syn 2.0.118",
1949+]
1950+
1951+[[package]]
1952+name = "tinystr"
1953+version = "0.8.3"
1954+source = "registry+https://github.com/rust-lang/crates.io-index"
1955+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
1956+dependencies = [
1957+ "displaydoc",
1958+ "zerovec",
1959+]
1960+
1961+[[package]]
1962+name = "typenum"
1963+version = "1.20.1"
1964+source = "registry+https://github.com/rust-lang/crates.io-index"
1965+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1966+
1967+[[package]]
1968+name = "unicode-ident"
1969+version = "1.0.24"
1970+source = "registry+https://github.com/rust-lang/crates.io-index"
1971+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1972+
1973+[[package]]
1974+name = "unicode-linebreak"
1975+version = "0.1.5"
1976+source = "registry+https://github.com/rust-lang/crates.io-index"
1977+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
1978+
1979+[[package]]
1980+name = "unicode-width"
1981+version = "0.2.2"
1982+source = "registry+https://github.com/rust-lang/crates.io-index"
1983+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1984+
1985+[[package]]
1986+name = "universal-hash"
1987+version = "0.5.1"
1988+source = "registry+https://github.com/rust-lang/crates.io-index"
1989+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
1990+dependencies = [
1991+ "crypto-common",
1992+ "subtle",
1993+]
1994+
1995+[[package]]
1996+name = "unsynn"
1997+version = "0.3.0"
1998+source = "registry+https://github.com/rust-lang/crates.io-index"
1999+checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a"
2000+dependencies = [
2001+ "mutants",
2002+ "proc-macro2",
2003+ "rustc-hash",
2004+]
2005+
2006+[[package]]
2007+name = "url"
2008+version = "2.5.8"
2009+source = "registry+https://github.com/rust-lang/crates.io-index"
2010+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2011+dependencies = [
2012+ "form_urlencoded",
2013+ "idna",
2014+ "percent-encoding",
2015+ "serde",
2016+ "serde_derive",
2017+]
2018+
2019+[[package]]
2020+name = "utf8_iter"
2021+version = "1.0.4"
2022+source = "registry+https://github.com/rust-lang/crates.io-index"
2023+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2024+
2025+[[package]]
2026+name = "utf8parse"
2027+version = "0.2.2"
2028+source = "registry+https://github.com/rust-lang/crates.io-index"
2029+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2030+
2031+[[package]]
2032+name = "uuid"
2033+version = "1.23.4"
2034+source = "registry+https://github.com/rust-lang/crates.io-index"
2035+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
2036+dependencies = [
2037+ "getrandom 0.4.3",
2038+ "js-sys",
2039+ "wasm-bindgen",
2040+]
2041+
2042+[[package]]
2043+name = "version_check"
2044+version = "0.9.5"
2045+source = "registry+https://github.com/rust-lang/crates.io-index"
2046+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2047+
2048+[[package]]
2049+name = "walkdir"
2050+version = "2.5.0"
2051+source = "registry+https://github.com/rust-lang/crates.io-index"
2052+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2053+dependencies = [
2054+ "same-file",
2055+ "winapi-util",
2056+]
2057+
2058+[[package]]
2059+name = "wasi"
2060+version = "0.11.1+wasi-snapshot-preview1"
2061+source = "registry+https://github.com/rust-lang/crates.io-index"
2062+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2063+
2064+[[package]]
2065+name = "wasip2"
2066+version = "1.0.4+wasi-0.2.12"
2067+source = "registry+https://github.com/rust-lang/crates.io-index"
2068+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
2069+dependencies = [
2070+ "wit-bindgen",
2071+]
2072+
2073+[[package]]
2074+name = "wasm-bindgen"
2075+version = "0.2.126"
2076+source = "registry+https://github.com/rust-lang/crates.io-index"
2077+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
2078+dependencies = [
2079+ "cfg-if",
2080+ "once_cell",
2081+ "rustversion",
2082+ "wasm-bindgen-macro",
2083+ "wasm-bindgen-shared",
2084+]
2085+
2086+[[package]]
2087+name = "wasm-bindgen-macro"
2088+version = "0.2.126"
2089+source = "registry+https://github.com/rust-lang/crates.io-index"
2090+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
2091+dependencies = [
2092+ "quote",
2093+ "wasm-bindgen-macro-support",
2094+]
2095+
2096+[[package]]
2097+name = "wasm-bindgen-macro-support"
2098+version = "0.2.126"
2099+source = "registry+https://github.com/rust-lang/crates.io-index"
2100+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
2101+dependencies = [
2102+ "bumpalo",
2103+ "proc-macro2",
2104+ "quote",
2105+ "syn 2.0.118",
2106+ "wasm-bindgen-shared",
2107+]
2108+
2109+[[package]]
2110+name = "wasm-bindgen-shared"
2111+version = "0.2.126"
2112+source = "registry+https://github.com/rust-lang/crates.io-index"
2113+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
2114+dependencies = [
2115+ "unicode-ident",
2116+]
2117+
2118+[[package]]
2119+name = "web-sys"
2120+version = "0.3.103"
2121+source = "registry+https://github.com/rust-lang/crates.io-index"
2122+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
2123+dependencies = [
2124+ "js-sys",
2125+ "wasm-bindgen",
2126+]
2127+
2128+[[package]]
2129+name = "winapi-util"
2130+version = "0.1.11"
2131+source = "registry+https://github.com/rust-lang/crates.io-index"
2132+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2133+dependencies = [
2134+ "windows-sys",
2135+]
2136+
2137+[[package]]
2138+name = "windows-core"
2139+version = "0.62.2"
2140+source = "registry+https://github.com/rust-lang/crates.io-index"
2141+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2142+dependencies = [
2143+ "windows-implement",
2144+ "windows-interface",
2145+ "windows-link",
2146+ "windows-result",
2147+ "windows-strings",
2148+]
2149+
2150+[[package]]
2151+name = "windows-implement"
2152+version = "0.60.2"
2153+source = "registry+https://github.com/rust-lang/crates.io-index"
2154+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2155+dependencies = [
2156+ "proc-macro2",
2157+ "quote",
2158+ "syn 2.0.118",
2159+]
2160+
2161+[[package]]
2162+name = "windows-interface"
2163+version = "0.59.3"
2164+source = "registry+https://github.com/rust-lang/crates.io-index"
2165+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2166+dependencies = [
2167+ "proc-macro2",
2168+ "quote",
2169+ "syn 2.0.118",
2170+]
2171+
2172+[[package]]
2173+name = "windows-link"
2174+version = "0.2.1"
2175+source = "registry+https://github.com/rust-lang/crates.io-index"
2176+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2177+
2178+[[package]]
2179+name = "windows-result"
2180+version = "0.4.1"
2181+source = "registry+https://github.com/rust-lang/crates.io-index"
2182+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2183+dependencies = [
2184+ "windows-link",
2185+]
2186+
2187+[[package]]
2188+name = "windows-strings"
2189+version = "0.5.1"
2190+source = "registry+https://github.com/rust-lang/crates.io-index"
2191+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2192+dependencies = [
2193+ "windows-link",
2194+]
2195+
2196+[[package]]
2197+name = "windows-sys"
2198+version = "0.61.2"
2199+source = "registry+https://github.com/rust-lang/crates.io-index"
2200+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2201+dependencies = [
2202+ "windows-link",
2203+]
2204+
2205+[[package]]
2206+name = "wit-bindgen"
2207+version = "0.57.1"
2208+source = "registry+https://github.com/rust-lang/crates.io-index"
2209+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2210+
2211+[[package]]
2212+name = "writeable"
2213+version = "0.6.3"
2214+source = "registry+https://github.com/rust-lang/crates.io-index"
2215+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
2216+
2217+[[package]]
2218+name = "x25519-dalek"
2219+version = "2.0.1"
2220+source = "registry+https://github.com/rust-lang/crates.io-index"
2221+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
2222+dependencies = [
2223+ "curve25519-dalek",
2224+ "rand_core 0.6.4",
2225+ "serde",
2226+ "zeroize",
2227+]
2228+
2229+[[package]]
2230+name = "yoke"
2231+version = "0.8.3"
2232+source = "registry+https://github.com/rust-lang/crates.io-index"
2233+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
2234+dependencies = [
2235+ "stable_deref_trait",
2236+ "yoke-derive",
2237+ "zerofrom",
2238+]
2239+
2240+[[package]]
2241+name = "yoke-derive"
2242+version = "0.8.2"
2243+source = "registry+https://github.com/rust-lang/crates.io-index"
2244+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
2245+dependencies = [
2246+ "proc-macro2",
2247+ "quote",
2248+ "syn 2.0.118",
2249+ "synstructure",
2250+]
2251+
2252+[[package]]
2253+name = "zerocopy"
2254+version = "0.8.52"
2255+source = "registry+https://github.com/rust-lang/crates.io-index"
2256+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
2257+dependencies = [
2258+ "zerocopy-derive",
2259+]
2260+
2261+[[package]]
2262+name = "zerocopy-derive"
2263+version = "0.8.52"
2264+source = "registry+https://github.com/rust-lang/crates.io-index"
2265+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
2266+dependencies = [
2267+ "proc-macro2",
2268+ "quote",
2269+ "syn 2.0.118",
2270+]
2271+
2272+[[package]]
2273+name = "zerofrom"
2274+version = "0.1.8"
2275+source = "registry+https://github.com/rust-lang/crates.io-index"
2276+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2277+dependencies = [
2278+ "zerofrom-derive",
2279+]
2280+
2281+[[package]]
2282+name = "zerofrom-derive"
2283+version = "0.1.7"
2284+source = "registry+https://github.com/rust-lang/crates.io-index"
2285+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
2286+dependencies = [
2287+ "proc-macro2",
2288+ "quote",
2289+ "syn 2.0.118",
2290+ "synstructure",
2291+]
2292+
2293+[[package]]
2294+name = "zeroize"
2295+version = "1.9.0"
2296+source = "registry+https://github.com/rust-lang/crates.io-index"
2297+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
2298+dependencies = [
2299+ "zeroize_derive",
2300+]
2301+
2302+[[package]]
2303+name = "zeroize_derive"
2304+version = "1.5.0"
2305+source = "registry+https://github.com/rust-lang/crates.io-index"
2306+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
2307+dependencies = [
2308+ "proc-macro2",
2309+ "quote",
2310+ "syn 2.0.118",
2311+]
2312+
2313+[[package]]
2314+name = "zerotrie"
2315+version = "0.2.4"
2316+source = "registry+https://github.com/rust-lang/crates.io-index"
2317+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
2318+dependencies = [
2319+ "displaydoc",
2320+ "yoke",
2321+ "zerofrom",
2322+]
2323+
2324+[[package]]
2325+name = "zerovec"
2326+version = "0.11.6"
2327+source = "registry+https://github.com/rust-lang/crates.io-index"
2328+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
2329+dependencies = [
2330+ "yoke",
2331+ "zerofrom",
2332+ "zerovec-derive",
2333+]
2334+
2335+[[package]]
2336+name = "zerovec-derive"
2337+version = "0.11.3"
2338+source = "registry+https://github.com/rust-lang/crates.io-index"
2339+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
2340+dependencies = [
2341+ "proc-macro2",
2342+ "quote",
2343+ "syn 2.0.118",
2344+]
2345+
2346+[[package]]
2347+name = "zmij"
2348+version = "1.0.21"
2349+source = "registry+https://github.com/rust-lang/crates.io-index"
2350+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
2351+
2352+[[package]]
2353+name = "zstd"
2354+version = "0.13.3"
2355+source = "registry+https://github.com/rust-lang/crates.io-index"
2356+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
2357+dependencies = [
2358+ "zstd-safe",
2359+]
2360+
2361+[[package]]
2362+name = "zstd-safe"
2363+version = "7.2.4"
2364+source = "registry+https://github.com/rust-lang/crates.io-index"
2365+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
2366+dependencies = [
2367+ "zstd-sys",
2368+]
2369+
2370+[[package]]
2371+name = "zstd-sys"
2372+version = "2.0.16+zstd.1.5.7"
2373+source = "registry+https://github.com/rust-lang/crates.io-index"
2374+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
2375+dependencies = [
2376+ "cc",
2377+ "pkg-config",
2378+]
Cargo.tomlnew file+54
@@ -0,0 +1,54 @@
1+[workspace]
2+members = ["core"]
3+resolver = "2"
4+
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+
18+[workspace.package]
19+edition = "2021"
20+rust-version = "1.90"
21+license = "MIT"
22+repository = "https://github.com/ilyakooo0/skrepka"
23+
24+[workspace.dependencies]
25+crux_core = "0.19"
26+crux_http = "0.18"
27+crux_kv = "0.13"
28+crux_time = "0.17"
29+boltffi = "0.26"
30+facet = { version = "=0.44", features = ["chrono"] }
31+serde = { version = "1.0", features = ["derive", "rc"] }
32+serde_json = "1.0"
33+
34+# crypto
35+ed25519-dalek = { version = "2", features = ["rand_core"] }
36+x25519-dalek = { version = "2", features = ["static_secrets"] }
37+curve25519-dalek = "4"
38+chacha20poly1305 = "0.10"
39+hkdf = "0.12"
40+sha2 = "0.10"
41+zstd = "0.13"
42+rand_core = { version = "0.6", features = ["getrandom"] }
43+rand_chacha = "0.3"
44+uuid = { version = "1", features = ["v4"] }
45+hex = "0.4"
46+base64 = "0.22"
47+zeroize = { version = "1", features = ["derive"] }
48+url = "2"
49+
50+# codegen bin
51+anyhow = "1.0"
52+clap = { version = "4", features = ["derive"] }
53+log = "0.4"
54+pretty_env_logger = "0.5"
Justfilenew file+49
@@ -0,0 +1,49 @@
1+# Skrepka iOS client (Rust core + Crux + SwiftUI shell)
2+#
3+# One-time setup:
4+# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
5+# cargo install boltffi_cli
6+# brew install xcodegen
7+
8+sim_id := env_var_or_default("SIM_ID", "")
9+bundle := "lol.skrepka.SkrepkaApp-iOS"
10+
11+default: test
12+
13+# Run the Rust core test suite (crypto, protocol, @p, state machine).
14+test:
15+ cargo test -p skrepka_core
16+
17+# Generate the Swift type bindings from the core.
18+typegen:
19+ cargo run -q -p skrepka_core --bin codegen --features codegen -- \
20+ --language swift --output-dir apple/generated/App
21+
22+# Build the core for Apple and package the xcframework + Swift package.
23+pack:
24+ cd core && boltffi pack apple
25+
26+# Generate the Xcode project from project.yml.
27+project:
28+ cd apple && xcodegen generate
29+
30+# Full regeneration: types, xcframework, Xcode project.
31+generate: typegen pack project
32+
33+# Build the iOS app for a booted simulator (set SIM_ID, or pass a destination).
34+build 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 )
36+ cd apple && xcodebuild -project SkrepkaApp.xcodeproj -scheme SkrepkaApp-iOS \
37+ -destination 'platform=iOS Simulator,id={{sim}}' \
38+ -derivedDataPath build -skipPackagePluginValidation build
39+
40+# Build, then install + launch on a booted simulator.
41+run sim=sim_id: (build sim)
42+ cd apple && xcrun simctl install {{sim}} \
43+ build/Build/Products/Debug-iphonesimulator/SkrepkaApp-iOS.app
44+ xcrun simctl launch {{sim}} {{bundle}}
45+
46+# Run a local relay server for testing (resets the disposable relay db first).
47+server:
48+ rm -f server.db server.db-wal server.db-shm
49+ ./server
PROTOCOL-REVIEW.mdnew file+416
@@ -0,0 +1,416 @@
1+# PROTOCOL.md Review — Holes vs. Implementation
2+
3+**Date:** 2026-06-20 (revised 2026-07-11 against the Rust core)
4+**Scope:** Cross-check of gaps found in `PROTOCOL.md` against the actual code
5+(`server.knot`, `core/` — the Rust/Crux shared core). Each item records whether
6+the implementation already handles it, partially handles it, or leaves it open.
7+
8+> The original review was written against the F# client, which has since been
9+> replaced by the Rust core in `core/` (logic) plus a logic-free SwiftUI shell in
10+> `apple/`. Citations below point at the Rust code; findings that the rewrite
11+> closed are marked as such.
12+
13+Legend: ✅ fixed · 🟡 partial / mitigated · ❌ open · ⚪ not-a-bug (spec wording only)
14+
15+---
16+
17+## ✅ 1. Auth challenge is not bound to the server (session relay) — fixed
18+
19+**Severity: high — fixed.**
20+
21+The client signed the bare challenge string and the server verified the bare
22+challenge string. Nothing bound the signed material to a server identity, so a
23+relay could forward another server's challenge and replay the signature.
24+
25+**Attack (historical):** a malicious/relay server the client connects to fetched
26+a challenge for the client's pubkey from a legit server A, handed it to the
27+client, collected the signature, and replayed it to A's `/auth/verify` —
28+obtaining a session **as the client** on A (could `/poll` the client's mailbox
29+and send as them). The challenge's IP-binding (`matchIp`) did not help, because
30+the relay server was the client-of-record to A throughout.
31+
32+**Fix (applied):** the client now signs
33+`"skrepka-auth-v1:" + server_host + ":" + challenge` where `server_host` is the
34+bare lowercased hostname it dialed (`Identity::sign_challenge`,
35+`core/src/crypto.rs`; the tag is `AUTH_TAG`), and the server verifies against
36+`"skrepka-auth-v1:" ++ beforeColon serverHost ++ ":" ++ challenge` using its own
37+configured `serverHost` (`server.knot` `handleVerifyAuth`). The `skrepka-auth-v1:`
38+prefix is a domain-separation tag so an auth signature can never be confused with
39+a message signature (which is over `recipientPub || compressed`). Breaking change:
40+client and server must be upgraded together.
41+
42+Operational corollary: a relay's `serverHost` **must** be the public hostname
43+clients dial, or every signature fails to verify. It defaults to `"localhost"`,
44+so `install.sh` passes `--serverHost=${DOMAIN}` in the systemd unit it writes.
45+
46+---
47+
48+## ❌ 2. Gossip presence spoofing redirects a victim's mail to the attacker
49+
50+**Severity: high — open (consequence of open federation).**
51+
52+`/federation/gossip` is unauthenticated. On a newly-`online` event the origin
53+forwards the recipient's queued messages **to the announcing `fromServer`**
54+(`handleRecvGossip` → `forwardQueuedToServer pk fromServer`, `server.knot`).
55+
56+**Attack:** a hostile peer announces `{eventType:"online", pubkey:<victim>,
57+fromServer:<attacker>}`. The origin forwards all of the victim's queued
58+ciphertext to the attacker, who harvests ciphertext copies + recipient/timing/size
59+metadata. The receiver-side "recipient must be locally online" gate is checked by
60+the attacker's own server, so it is not a real gate.
61+
62+**Existing partial defense:** the `isBadServerName` SSRF deny-list blocks
63+internal targets, and gossip is rate-limited per peer IP. Neither authenticates
64+the peer, and the deny-list is purely lexical — a public-looking hostname under
65+attacker DNS control still passes (documented at `isBadServerName`).
66+
67+`PROTOCOL.md` §10 understates this as "a hostile server can observe gossip" — it
68+can actively **redirect delivery**. At minimum the spec should say so; a real fix
69+needs signed/authenticated presence or peer allow-listing.
70+
71+---
72+
73+## ✅ 3. Replay protection is narrower than §10 claims — closed in the Rust core
74+
75+**Severity: medium — fixed.**
76+
77+§10 claimed "Replay protection"; the underlying mechanism is the
78+recipient-bound signature, which stops replay to a *different* recipient, not
79+re-delivery of the same blob to the same recipient. Per-type status, all in
80+`ingest_poll` (`core/src/app.rs`):
81+
82+- `text` — ✅ deduplicated by `id` before it is appended to a conversation.
83+- `delivery.ack` — ✅ harmless: it only flips `Sent → Delivered`, idempotent.
84+- `profile` — ✅ **fixed** (was open in the F# client): each `Contact` carries
85+ `last_profile_ts` (`core/src/model.rs`), and an incoming `profile` whose `ts`
86+ predates it is dropped instead of applied, so a replayed *older* profile can no
87+ longer roll a contact's name/bio/photo back.
88+
89+`PROTOCOL.md` §4 and §10 have been updated to say the reference client enforces
90+the staleness check. §10's replay wording should still be read as "replay against
91+a *different* recipient" — re-delivery to the same recipient is prevented by the
92+per-type guards above, not by the crypto.
93+
94+---
95+
96+## 🟡 4. Open `/federation/forward` allows mailbox injection
97+
98+**Severity: medium — mitigated, not closed.**
99+
100+Any host can POST a blob for any locally-online `toKey`; the endpoint is
101+unauthenticated.
102+
103+- Mitigations present: per-IP rate limit (`forwardRateLimit`, `server.knot`),
104+ the SSRF filter on origins, and the `no_presence` gate.
105+- Residual: junk blobs still reach an online recipient's poll stream; the spec
106+ never says what a client does with an undecryptable blob. The core drops it
107+ silently — `crypto::decrypt` returning `Err` just `continue`s the ingest loop
108+ (`ingest_poll`, `core/src/app.rs`). Worth documenting as an accepted,
109+ rate-limited spam surface.
110+
111+---
112+
113+## ⚪ 5. `receivedAt` "millisecond timestamp" vs "monotonic" — wording only
114+
115+**Not a bug.** `appendMessage` assigns `seqTs = max t (currentSeq seqRows + 1)`
116+(`server.knot`), which is **strictly monotonic**, so no two messages share a
117+`receivedAt` and the `cursor >= receivedAt` implicit-ack cannot drop an unseen
118+message. The spec just describes the same value as both a "millisecond timestamp"
119+and "monotonic," which reads as ambiguous. Recommend: describe it as "a
120+monotonic sequence seeded from millisecond wall-clock; treat as an opaque
121+checkpoint, not a clock."
122+
123+---
124+
125+## ✅ 6. Total request-body cap — fixed in deployment, undocumented in spec
126+
127+The per-blob cap is `maxBlobLen` (40 MiB hex) enforced by the `BlobHex` type, and
128+a batch is capped at `maxBatchSize = 100` (`server.knot`). The naive reading
129+"100 × 40 MiB = 4 GiB request" is **prevented** by a runtime body cap set in
130+deployment:
131+
132+- `install.sh` — `ExecStart=… --http-max-body-bytes=42M` caps the *entire*
133+ request body (chosen just above `maxBlobLen` + envelope; the runtime default is
134+ 16 MiB, which would reject a legitimate max-size blob with a bare 413).
135+
136+So a batch can carry one max-size blob or many small ones, but total ≤ 42 MiB.
137+`PROTOCOL.md` §5 should state this total-body cap (and that operators must keep
138+`--http-max-body-bytes` > `maxBlobLen`).
139+
140+---
141+
142+## ✅ 7. No crypto / wire-format version negotiation — fixed
143+
144+**Severity: low — fixed.**
145+
146+`HKDF_INFO = "skrepka-v1"` is hardcoded (`core/src/crypto.rs`), and the plaintext payload carries no version field. The wire format now carries a leading version byte (`WIRE_VERSION = 0x01`, `core/src/crypto.rs`): `encrypt` prepends it, and `decrypt` reads and checks it before any other processing, returning `CryptoError::Decrypt` on mismatch. `MIN_BLOB_LEN` includes the extra byte (169 bytes). "Ignore unknown `type`" (`protocol::parse_payload`) gives payload-type agility; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes crypto primitives should bump both the version byte and the HKDF `info` string.
147+
148+---
149+
150+## ⚪ 8. X-Forwarded-For trust is spoofable without a proxy — by design
151+
152+`trustForwardedFor = True` by default (`server.knot`); the session/rate-limit IP
153+is the last XFF hop (`pickClientIp`), and `matchIp` treats an empty bound IP as a
154+wildcard. This is correct **behind a trusted proxy** (the `install.sh` topology,
155+Caddy in front) but spoofable when the server is directly exposed — a client could
156+forge the header to mint fresh rate-limit buckets. The flag and its
157+`--trustForwardedFor=False` override exist; the spec presents IP-binding as a
158+protection without the proxy caveat. Recommend documenting it.
159+
160+---
161+
162+## Minor / noted
163+
164+- The SSRF deny-list (`isBadServerName`) is **lexical only**: it filters the
165+ hostname string, never the address it resolves to, so an attacker-controlled
166+ name that resolves to a private IP passes. Closing it requires resolve-then-pin,
167+ which the runtime's `fetch` does not expose. Documented in place.
168+- HKDF salt is `ephemeral_pub || recipient_x25519_pub` (both public), binds no
169+ sender — fine for confidentiality, no KCI/UKS guarantee beyond the signature.
170+- Presence TTL (90 min, `onlineGossipTtl`) outlives the 1 h session, so location
171+ metadata and stale forwards can persist ~90 min after disconnect.
172+- `/auth/challenge` is unauthenticated; fillable by anyone but capped
173+ (`maxChallenges = 10000`, `maxChallengesPerKey = 5`) and rate-limited per
174+ client IP. It is deliberately **not** limited per submitted pubkey: keypairs are
175+ free, so a pubkey-keyed bucket refills on every rotation (Sybil), and it would
176+ also let an attacker exhaust a victim's login budget.
177+
178+---
179+
180+## 🟡 9. Federation retry loop can starve the prune sweep
181+
182+**Severity: low — design tradeoff, not a code bug.**
183+
184+`backgroundRetryForwards` runs `retryPendingForwards` inline (not via `fork`),
185+so a sweep that hits many slow or timing-out peers blocks until every
186+`fetch` in the batch resolves. Each `fetch` has no explicit timeout — the
187+runtime's HTTP client holds the connection — so a sweep over N unreachable
188+peers can take N × (TCP connect timeout) before returning. During that time
189+`backgroundPrune` (on its own `fork`) is free to run, but its writes to
190+`*messages` and `*forwardFailures` conflict with `retryPendingForwards`'s
191+`atomic` blocks, so `pruneExpired`'s atomic retries lose to the retry loop
192+and are effectively starved: expired messages, sessions, and challenges
193+pile up until the retry sweep finishes.
194+
195+In practice this only matters under federation with many dead peers and a
196+low `retryBackoffMax` — the `shouldRetryServer` gate skips peers in backoff,
197+so after one failed sweep most peers are gated out of the next one. The
198+first sweep after a network partition is the worst case.
199+
200+**No fix applied.** The deliberate design (inline, not forked) prevents two
201+concurrent `retryPendingForwards` from seeing the same `*forwards` snapshot
202+and double-counting `recordFailure`. Forking it would reintroduce that race.
203+A real fix would either (a) give `fetch` a deadline and bound the sweep
204+time, or (b) run `retryPendingForwards` in a transaction that yields
205+`*messages`/`*forwardFailures` to `pruneExpired` between per-peer batches.
206+Both need runtime support the current `fetch` does not expose.
207+
208+---
209+
210+## Suggested follow-ups
211+
212+1. **Spec edits (safe, no behavior change):** fold #2, #4, #6, #8 caveats into
213+ `PROTOCOL.md` (§6 auth, §7 federation, §5 size, §10 security). #3's spec text
214+ is already updated.
215+2. **Code fixes:** #1 (bind challenge to server) and #3 (profile `ts` staleness
216+ guard) are **done**.
217+3. #2 and #4 are inherent to open, unauthenticated federation — decide whether to
218+ document-as-accepted or add peer authentication.
219+4. **#9** is a design tradeoff; document as accepted until `fetch` supports a
220+ deadline.
221+
222+---
223+
224+## Findings blocked on runtime/protocol support (2026-07-13 audit)
225+
226+These findings are documented here for tracking. Each is blocked on a capability
227+the current runtime or protocol does not expose, so no code fix is possible
228+today.
229+
230+### ❌ A. `now_ms()` impurity breaks MVU replayability
231+
232+**Severity: medium — blocked on `crux_time` refactor.**
233+
234+`now_ms()` reads the system clock from inside `update()` (app.rs:427), which is
235+supposed to be a pure function of `(Event, Model)`. This affects `SendText`,
236+`SaveProfile`, `ingest_poll`, `flush_next` (first_attempt stamping), and ack
237+timestamping. The state machine cannot be replayed deterministically, and tests
238+cannot pin "now".
239+
240+The fix is to take the time as a `crux_time` effect and feed it back as an
241+event — but every timestamped path would become a two-step round-trip through
242+the shell. A TODO comment is in place at the call site. This refactor is worth
243+doing but is not a drive-by fix.
244+
245+### ❌ B. Token comparison timing side-channel
246+
247+**Severity: medium — blocked on Knot runtime.**
248+
249+`s.token == token` is plain string equality, which short-circuits on the first
250+differing byte (server.knot:761). An attacker who can time the response learns
251+how many leading characters of a guess were correct. Bounded by the 192-bit
252+token and per-IP rate limit. The correct fix is a `constantTimeEquals` primitive
253+in the Knot runtime. Documented in PROTOCOL.md §6 and in-code comments.
254+
255+### ❌ C. Open federation — gossip redirect & forward injection
256+
257+**Severity: high — accepted v0.1 limitation, blocked on peer authentication.**
258+
259+Both `/federation/*` endpoints are unauthenticated. An attacker can redirect a
260+victim's queued ciphertext or inject blobs into an online recipient's mailbox.
261+Fully documented in PROTOCOL-REVIEW.md #2, #4 and PROTOCOL.md §10. The fix
262+requires signed presence gossip and authenticated forward requests, which needs
263+a key-to-home-server binding the protocol does not yet define.
264+
265+### ❌ D. SSRF deny-list is lexical only
266+
267+**Severity: medium — blocked on `fetch` exposing DNS resolution.**
268+
269+`isBadServerName` inspects the hostname string, never the resolved address.
270+DNS-rebinding TOCTOU bypasses it. The fix needs resolve-then-pin (fuse DNS
271+resolution with connection), which `fetch` does not expose. Documented in
272+PROTOCOL-REVIEW.md and in-code comments at `isBadServerName`.
273+
274+### ❌ E. Federation retry sweep can starve the prune sweep
275+
276+**Severity: low — design tradeoff, blocked on `fetch` deadline support.**
277+
278+`backgroundRetryForwards` runs inline, so a sweep over many unreachable peers
279+blocks until every `fetch` resolves. The prune sweep's writes conflict with the
280+retry loop's atomic blocks. A fix needs either a `fetch` deadline or a
281+transaction that yields between per-peer batches, neither of which the current
282+`fetch` supports. Documented in PROTOCOL-REVIEW.md #9.
283+
284+---
285+
286+## Fixed in third-pass audit (2026-07-13)
287+
288+### ✅ F. Cursor advanced before budget check — message loss on oversized poll page
289+
290+**Severity: high — fixed.**
291+
292+`ingest_poll` advanced `model.cursor` to `page.cursor` before the event loop.
293+If the `MAX_POLL_TOTAL_BYTES` budget was exhausted mid-page, the loop broke
294+but the cursor stayed at `page.cursor`. The next poll acked the whole page to
295+the relay, which deleted every event the client never processed — permanent
296+message loss. A relay serving 4+ large blobs (each up to 20 MiB) in a single
297+50-event page was enough to trigger this.
298+
299+**Fix:** Save the pre-page cursor and restore it when the budget is exhausted.
300+
301+### ✅ G. `send_text` did not check if peer is blocked
302+
303+**Severity: medium — fixed.**
304+
305+Blocking was documented as cutting a peer off in both directions, and the
306+ingest side correctly dropped blocked peers' messages. But `send_text` had no
307+block check, so a user could keep sending messages to a blocked contact. The
308+Swift `ChatView` composer was also not disabled for blocked peers.
309+
310+**Fix:** `send_text` now refuses with an error when the active peer is blocked.
311+The Swift composer disables both the text field and the send button when
312+`activePeerBlocked` is true.
313+
314+### ✅ H. `compressed` and `signed` intermediates not zeroized in `crypto::encrypt`
315+
316+**Severity: low — fixed.**
317+
318+The `inner` buffer was correctly `Zeroize`d after encryption, but the
319+`compressed` (compressed plaintext) and `signed` (signature input containing
320+recipient_pub + compressed + padding) intermediates were plain `Vec<u8>`s
321+that retained sensitive data in freed memory. Both are now `Zeroizing<Vec<u8>>`.
322+
323+### ✅ I. PROTOCOL.md §10 "No cryptographic agility" contradicted §3
324+
325+**Severity: spec only — fixed.**
326+
327+The §10 risk table said "The wire format has no version field" but §3 had
328+been updated to describe a version byte (`0x01`) and a "Cryptographic
329+Versioning" section. The table entry now accurately reflects the version
330+byte's existence and its limitations.
331+
332+### ✅ J. PROTOCOL.md §4 "Blocking" didn't match implementation
333+
334+**Severity: spec only — fixed.**
335+
336+The spec said blocked senders' messages "are still received and decrypted but
337+are not displayed." The implementation drops them after decryption (the sender
338+identity is inside the AEAD ciphertext, so decryption is necessary to learn who
339+the message is from). The spec now accurately describes the implementation:
340+messages are decrypted to recover the sender identity, then dropped without
341+storage, display, or ack, and blocking is bidirectional.
342+
343+---
344+
345+## Fixed in sixth-pass audit (2026-07-13)
346+
347+### ✅ K. No send-side body length check — silent message loss
348+
349+**Severity: medium — fixed.**
350+
351+`send_text` did not check `body.len()` against `protocol::MAX_BODY_LEN`. The
352+recipient's `parse_payload` enforces the cap and silently drops oversized text,
353+so a user who pastes a very long message would see it stored locally, sent to
354+the relay, and marked "sent" — but it would never arrive, never get acked, and
355+the sender would see it stuck as "sent" forever with no error.
356+
357+**Fix:** `send_text` now refuses with `"message too long"` before queueing.
358+
359+### ✅ L. No profile field length checks in SaveProfile — silent broadcast failure
360+
361+**Severity: medium — fixed.**
362+
363+`SaveProfile` broadcast `display_name`, `bio`, and `photo` without checking
364+against `MAX_DISPLAY_NAME_LEN` (128 chars), `MAX_BIO_LEN` (1024 chars), or
365+`MAX_PHOTO_LEN` (64 KiB). Every recipient's `parse_payload` would silently drop
366+the oversized profile, creating a divergence between what the sender sees and
367+what contacts see.
368+
369+**Fix:** All three fields are validated before the profile is saved or broadcast.
370+
371+### ✅ M. `Decrypted` plaintext not zeroized
372+
373+**Severity: low — fixed.**
374+
375+The `inner` buffer and `signed` intermediate were carefully `Zeroizing<Vec<u8>>`,
376+but the output `Decrypted { plaintext: Vec<u8> }` escaped the zeroization
377+boundary. The decrypted message content sat in freed memory until the allocator
378+reused that page. `plaintext` is now `Zeroizing<Vec<u8>>`.
379+
380+### ✅ N. No send watchdog — stuck `flushing` guard
381+
382+**Severity: low — fixed.**
383+
384+The poll loop had a watchdog (`POLL_WATCHDOG_MS`) that recovered from a dropped
385+HTTP effect, but the send loop had no equivalent. If `SendResult` never came
386+back (a cancelled task, a backgrounded app, a bug), `flushing` stayed `true`
387+forever and the outbox was permanently stuck. A `FlushWatchdog` event (armed
388+alongside the send, same pattern as the poll watchdog) now releases the guard
389+and retries the flush after `FLUSH_WATCHDOG_MS` (90 s).
390+
391+### ✅ O. No nickname length check in AddContact
392+
393+**Severity: very low — fixed.**
394+
395+The `nickname` in `AddContact` was stored without any length bound. A user could
396+set a megabyte-long nickname, bloating the contacts kv blob (rewritten in full on
397+every change). `nickname.len()` is now checked against `MAX_DISPLAY_NAME_LEN`.
398+
399+---
400+
401+## Fixed in seventh-pass audit (2026-07-14)
402+
403+### ✅ P. `encrypt`'s `inner` buffer not zeroized on AEAD failure
404+
405+**Severity: low — fixed.**
406+
407+The `inner` buffer in `crypto::encrypt` (sender pubkey || signature ||
408+compressed_len || compressed || padding) was a plain `Vec<u8>`. It was
409+explicitly `zeroize()`d on the success path, but if `cipher.encrypt()`
410+returned `Err`, the `?` operator returned early and `inner` was dropped
411+without zeroization — the sender's identity, signature, and compressed
412+plaintext remained in freed memory. Every other sensitive intermediate in
413+`encrypt` (`compressed`, `signed`, `padding`, `eph_priv`, `raw_secret`,
414+`key`) was already `Zeroizing`; `inner` was the sole exception. Now
415+`Zeroizing<Vec<u8>>`, matching the pattern established for `decrypt`'s
416+`inner` and `signed` in the fourth-pass audit.
PROTOCOL.md+243−60
@@ -1,6 +1,6 @@
11# Skrepka Protocol Specification
22
3−**Version:** 0.1 (Draft)
3+**Version:** 0.2 (Draft)
44
55---
66
@@ -45,7 +45,24 @@ Public keys, signatures, and encrypted blobs are encoded as **lowercase hexadeci
4545Identities are shared out-of-band as plain text or QR codes. The client displays public keys in two forms:
4646
4747- **Hex** — 64 lowercase hex characters (the canonical wire form).
48−- **@p (phonetic)** — the public key rendered as Urbit-style syllables (e.g. `~ridler-binzod-mavpub-...`). Easier for humans to read and verify by voice. The client accepts either form when adding a contact.
48+- **@p (phonetic)** — the public key rendered as Urbit-style syllables, hyphen-joined with no leading prefix (e.g. `ridler-binzod-marbud-...`). Easier for humans to read and verify by voice. The client accepts either form when adding a contact.
49+
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.
4966
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
@@ -69,7 +86,7 @@ There is no global directory. Users assign local nicknames to public keys on the
6986| Key agreement | X25519 |
7087| Key derivation | HKDF-SHA256 |
7188| Symmetric cipher | XChaCha20-Poly1305 |
72−| Compression | LZ4 (pre-encryption) |
89+| Compression | zstd (pre-encryption) |
7390
7491### Per-Message Encryption
7592
@@ -93,47 +110,61 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
93110 key = hkdf_sha256(
94111 ikm = raw_secret,
95112 salt = ephemeral_public || recipient_x25519_public,
96− info = "skrepka-v1",
113+ info = "skrepka-v2",
97114 len = 32
98115 )
99116 ```
100−4. LZ4-compress the plaintext payload (see §4):
117+4. zstd-compress the plaintext payload (see §4):
101118 ```
102− compressed = lz4_pickle(plaintext_bytes)
119+ compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
103120 ```
104−5. Sign the recipient pubkey concatenated with the compressed plaintext. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
121+5. Sign a domain-separation tag, the recipient pubkey, and the compressed plaintext with padding. The `"skrepka-msg-v2:"` prefix is a domain-separation tag distinguishing message signatures from auth-challenge signatures (which begin with `"skrepka-auth-v1:"`, §6), so a signature produced in one context can never validate in the other. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
105122 ```
123+ compressed_len = uint32_be(len(compressed))
124+ padding = random_bytes(target_blob_size - unpadded_blob_size)
106125 signature = ed25519_sign(sender_ed25519_private,
107− recipient_ed25519_public || compressed)
126+ "skrepka-msg-v2:" || recipient_ed25519_public || compressed_len || compressed || padding)
108127 ```
1091286. Build the inner buffer that will be encrypted:
110129 ```
111− inner = sender_ed25519_public || signature || compressed
130+ inner = sender_ed25519_public || signature || compressed_len || compressed || padding
112131 ```
113−7. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer:
132+7. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer with
133+ associated data binding the envelope header and recipient identity:
114134 ```
115135 nonce = random_bytes(24)
116− ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner) // no associated data
136+ aad = version_byte || ephemeral_public || nonce || recipient_ed25519_public
137+ ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner, aad)
117138 ```
139+ The AAD authenticates every cleartext field a relay can see (version,
140+ ephemeral public key, nonce) and the recipient's Ed25519 public key.
141+ A relay that swaps the envelope `to` field to a different recipient causes
142+ AEAD decryption to fail at the wrong recipient — the blob is dropped before
143+ any cursor advance, and the original recipient's message stays in the
144+ mailbox. Without AAD, a relay could silently misdeliver messages by
145+ swapping `to`, making the censorship undetectable to both sender and
146+ recipient.
1181478. Emit the on-wire blob:
119148 ```
120− blob = ephemeral_public || nonce || ciphertext
149+ blob = version_byte || ephemeral_public || nonce || ciphertext
121150 ```
151+ where `version_byte` is `0x02` (see *Cryptographic Versioning* below).
122152
123153**Decryption flow:**
124154
125−1. Split the blob: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
155+1. Read the leading version byte (reject the blob if it is not recognized). Split the rest: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
1261562. Derive the recipient's X25519 private key from the recipient's Ed25519 private key, compute the raw shared secret with `ephemeral_public`, and HKDF-derive the same `key`.
127−3. AEAD-decrypt the ciphertext to recover the inner buffer.
128−4. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, remainder is `compressed`.
129−5. Verify the signature over `recipient_ed25519_public || compressed` using `sender_ed25519_public`. Reject the message on failure.
130−6. LZ4-decompress to recover the plaintext payload.
157+3. Reconstruct the AAD: `version_byte || ephemeral_public || nonce || recipient_ed25519_public`. AEAD-decrypt the ciphertext with this AAD to recover the inner buffer.
158+4. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, 4-byte `compressed_len` (big-endian u32), then `compressed_len` bytes of `compressed` (the remainder is padding).
159+5. Verify the signature over `"skrepka-msg-v2:" || recipient_ed25519_public || inner[96..]` (i.e. the domain-separation tag followed by `compressed_len || compressed || padding`) using `sender_ed25519_public`. Reject the message on failure.
160+6. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
131161
132162### Encrypted Blob Format
133163
134164On the wire (cleartext, server-visible):
135165
136166```
167+version (1 byte, currently 0x02)
137168ephemeral_x25519_pubkey (32 bytes)
138169nonce (24 bytes)
139170ciphertext (variable, includes 16-byte AEAD tag)
@@ -144,10 +175,34 @@ After AEAD decryption, the inner buffer is:
144175```
145176sender_ed25519_pubkey (32 bytes)
146177signature (64 bytes)
147−compressed_plaintext (variable, LZ4)
178+compressed_len (4 bytes, big-endian u32)
179+compressed_plaintext (variable, zstd; exactly compressed_len bytes)
180+padding (variable, random)
148181```
149182
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.
183+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 `compressed_len` field tells the recipient exactly where the zstd frame ends, so trailing padding bytes are never fed to the decompressor. The minimum blob size is `1 + 32 + 24 + 32 + 64 + 4 + 16 = 173` bytes (346 hex chars). A relay rejects any blob shorter than this with `400 invalid_message`, since it cannot possibly carry a valid envelope.
184+
185+### Length Padding
186+
187+Blob sizes are padded to fixed boundaries so the on-wire length does not reveal the exact compressed (and thus plaintext) size to relays and network observers. The padding is added *inside* the AEAD ciphertext (after the compressed data, before encryption), so the server only sees the padded size.
188+
189+The bucket boundaries are:
190+
191+```
192+256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536
193+```
194+
195+A blob is rounded up to the smallest bucket ≥ its unpadded size. Above 65536 bytes, the blob is rounded up to the next multiple of 65536. The padding bytes are randomly generated per message, so two encryptions of the same plaintext produce the same blob size (same bucket) but different blob contents. The on-wire blob is therefore `173 + len(compressed) + len(padding)` bytes, and that padded size is all a relay or network observer sees.
196+
197+> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: without length padding the blob would be exactly `173 + len(compressed)` bytes, and that number would be visible to every relay and network observer. Version 0x02 adds length padding (see *Length Padding* above), which buckets blob sizes to fixed boundaries so the exact compressed size is hidden.
198+>
199+> Two consequences. First, message length is only ever obscured *upward*: a long but repetitive message can produce a smaller blob than a short random-looking one, and blob size still distinguishes a one-word reply from a pasted document. Second — the more serious one — where an attacker can influence part of the plaintext that is compressed together with a secret, the compressed size reveals whether the two share structure. That is the CRIME/BREACH pattern, and it is a real (if narrow) concern for a format that compresses attacker-influenceable text alongside sensitive text in one frame.
200+>
201+> Skrepka accepts the remaining risk: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). Length padding (version 0x02) obscures the exact compressed size by bucketing blob lengths, but does not eliminate the CRIME/BREACH side channel within a single bucket — it only makes the granularity coarser.
202+
203+### Cryptographic Versioning
204+
205+The wire format carries a leading **version byte** (currently `0x02`) on every encrypted blob. A recipient that sees a version it does not recognise MUST reject the blob without attempting further processing (no AEAD decrypt, no key derivation, no decompression) — the reference client returns a decryption error and drops the blob. This ensures a future revision can introduce a new AEAD, KDF, or curve by bumping this byte without risk of misinterpreting an unknown format. Version 0x02 adds AEAD associated data (AAD): the cleartext header `version_byte || ephemeral_public || nonce` and the recipient's Ed25519 public key are authenticated alongside the ciphertext, so a relay that tampers with any envelope field — including swapping the `to` recipient — causes AEAD decryption to fail rather than silently misdelivering the blob. The HKDF `info` string is the fixed constant `"skrepka-v2"` for version 0x02. The unknown-`type` rule (§4) gives forward compatibility for *payload* types; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes the crypto primitives should bump both the version byte and the HKDF `info` string together — the reference implementation derives the info string from the version to ensure they cannot drift (see `crypto.rs`).
151206
152207---
153208
@@ -166,27 +221,26 @@ The envelope is what servers see. It carries only the recipient and the opaque e
166221
167222| Field | Type | Description |
168223|-----------------|--------|--------------------------------------------------|
169−| `to` | string | Recipient's Ed25519 public key (64 lowercase hex)|
170−| `encryptedBlob` | string | Hex-encoded blob from §3 |
224+| `to` | string | Recipient's Ed25519 public key — exactly 64 lowercase hex chars. The server validates the **format** only (length and charset); it does not verify the decoded bytes form a valid Ed25519 curve point. |
225+| `encryptedBlob` | string | Hex-encoded blob from §3. Must be at least 346 hex chars (173 bytes); the server rejects shorter blobs with `400 invalid_message`. |
171226
172−Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not know the sender.
227+Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not know the sender. The `to` field is format-validated only — the server does not check that it represents a valid Ed25519 curve point, so blobs addressed to non-existent keys are stored and forwarded normally (the intended recipient simply cannot decrypt them). A message whose `to` matches the authenticated sender's own pubkey is rejected with `400 self_send` (§6, *POST /messages*).
173228
174229### Plaintext Payload
175230
176−After decryption (and LZ4 decompression), the plaintext is a UTF-8 JSON object with a `type` field:
231+After decryption (and zstd decompression), the plaintext is a UTF-8 JSON object with a `type` field:
177232
178233```json
179234{
180235 "type": "<message_type>",
181− "id": "<unique_message_id>",
182236 "ts": 1679000000000,
183237 ...
184238}
185239```
186240
187−- `ts` is the sender's wall-clock at send time, in **milliseconds** since the Unix epoch.
188−- `id` is a client-generated unique ID (e.g. UUID v4). Clients MUST deduplicate incoming messages by `id`.
189−- Clients MUST silently ignore message types they do not recognize. This allows newer clients to introduce new types without breaking older ones.
241+- `type` and `ts` are present on every payload. `ts` is the sender's wall-clock at send time, in **milliseconds** since the Unix epoch.
242+- `id` is a client-generated unique ID (e.g. UUID v4) carried by `text` messages only. Clients deduplicate incoming `text` messages by `id`. `delivery.ack` and `profile` payloads do not carry an `id`.
243+- Clients MUST silently drop message types they do not recognize — no error is surfaced to the sender, and the blob is treated as if it were not there. This allows newer clients to introduce new types without breaking older ones.
190244
191245### Message Types
192246
@@ -200,6 +254,21 @@ The current implementation defines three plaintext message types:
200254
201255Future message types (media attachments, read receipts, group messages) are not part of this revision.
202256
257+### Payload Size Limits
258+
259+A decrypted payload is attacker-controlled: a peer can put any value in any field. The reference client enforces the following caps and silently drops a payload that exceeds any of them (treated identically to an unknown type). An independent client SHOULD enforce the same caps, or it risks storing, rendering, and re-transmitting unbounded attacker-supplied data.
260+
261+| Field | Cap | Rationale |
262+|-------------------|-------------|--------------------------------------------------------|
263+| `text.body` | 64 KiB | A text body is never legitimately this long. |
264+| `text.id` | 128 chars | IDs are UUIDs; 128 chars is generous. |
265+| `profile.display_name` | 128 chars (Unicode) | A display name is not a bio. |
266+| `profile.bio` | 1024 chars (Unicode) | Enough for a paragraph. |
267+| `profile.photo` | 64 KiB (base64) | A 256px JPEG is ~10-30 KiB; 64 KiB is headroom. |
268+| `delivery.ack.ack_ids` | 1024 entries | Bounds the O(n·m) scan a peer can dictate. |
269+
270+The caps on `display_name` and `bio` are measured in Unicode characters (not bytes), so a multi-byte emoji costs the same as an ASCII letter. A payload sitting exactly at the cap is accepted; the bound is inclusive.
271+
203272### Profiles
204273
205274Users share a display name, bio, and photo with contacts by sending a `profile` message. This is a regular encrypted message — servers never see profile data.
@@ -207,7 +276,6 @@ Users share a display name, bio, and photo with contacts by sending a `profile`
207276```json
208277{
209278 "type": "profile",
210− "id": "msg_456",
211279 "ts": 1679000000000,
212280 "display_name": "Alice",
213281 "bio": "writing things",
@@ -217,6 +285,8 @@ Users share a display name, bio, and photo with contacts by sending a `profile`
217285
218286Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name, bio, or photo. The recipient's client caches the profile locally. `photo` is optional and MAY be omitted.
219287
288+Because `profile` messages carry no `id`, they are not deduplicated, and a captured `profile` blob can be replayed to the same recipient (see §10, *Same-recipient replay*). To prevent an old profile from clobbering a newer one, clients SHOULD ignore an incoming `profile` whose `ts` is older than the `ts` of the last profile already stored for that contact. **The reference client enforces this**: it records the `ts` of the most recent profile accepted per contact and drops any incoming `profile` that predates it, so a replayed stale profile cannot roll a contact's cached name, bio, or photo back. A client that skipped the check would be vulnerable to exactly that rollback.
289+
220290### Delivery Acks
221291
222292When a recipient successfully decrypts an incoming `text` message, the recipient's client SHOULD send a `delivery.ack` message back to the sender:
@@ -233,7 +303,7 @@ This is the only delivery-confirmation channel in the protocol: the server itsel
233303
234304### Blocking
235305
236−Blocking is client-side only. A blocked sender's messages are still received and decrypted but are not displayed to the user. The protocol does not provide a mechanism to tell a server to reject messages from a specific pubkey.
306+Blocking is client-side only. A blocked sender's messages are decrypted to recover the sender identity (which lives inside the AEAD ciphertext, §3), then dropped without storage, display, or ack — the reference client does not store, display, or ack them. Decryption is necessary because the sender identity is inside the AEAD ciphertext; the decrypted content is never persisted or shown. The protocol does not provide a mechanism to tell a server to reject messages from a specific pubkey. Blocking also cuts the peer off in the other direction: the reference client refuses to send new messages to a blocked contact and drops any queued outgoing messages (including delivery acks) for them when the block is applied.
237307
238308---
239309
@@ -264,19 +334,29 @@ Skrepka has no central directory. Clients are configured with a single server UR
264334]
265335```
266336
267−This file is a static suggestion, not a federation directory. Federation between servers is configured server-side, independently of any client-facing list.
337+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.
268338
269339### Content Type
270340
271−All request and response bodies use `application/json`.
341+All request and response bodies use `application/json`. The following encoding rules apply to every JSON body (request and response):
342+
343+- **Encoding:** UTF-8. Implementations MUST NOT use UTF-16 or UTF-32.
344+- **Key ordering:** Object keys are unordered. A sender may emit them in any order; a receiver MUST NOT depend on key ordering for correctness.
345+- **Whitespace:** Inter-token whitespace (spaces, tabs, newlines) is allowed and does not affect semantics. Pretty-printed JSON is valid.
346+- **Duplicate keys:** If an object contains the same key more than once, behavior is undefined. Implementations SHOULD avoid producing duplicates and MAY reject or retain only the first value for a repeated key.
272347
273348### Federation Transport
274349
275−Servers contact peers over `https://<peer-host>`. Federation traffic carries no bearer tokens — federation endpoints are open to any caller, with abuse-related defenses (SSRF egress filtering, presence-gated forwarding, per-peer linear backoff) handled internally. See §7.
350+Servers contact peers over `https://<peer-host>`. Federation traffic carries no bearer tokens — federation endpoints are open to any caller, with abuse-related defenses (SSRF egress filtering, presence-gated forwarding, per-peer linear backoff) handled internally. Those defenses bound abuse; they do **not** authenticate the peer, and an unauthenticated caller can both redirect a victim's queued mail (via `/federation/gossip`) and inject blobs into an online recipient's mailbox (via `/federation/forward`). Both are accepted limitations of v0.1 — see §7 for the endpoints and §10 for the security analysis.
276351
277352### Message Size Limit
278353
279−The on-wire `encryptedBlob` (hex) is capped at **40 MiB** (about 20 MiB of binary payload). Servers reject larger messages.
354+The on-wire `encryptedBlob` (hex) is capped at **40 MiB** (about 20 MiB of binary payload). A blob over the cap is rejected at validation as a malformed message (`400`), not with `413`.
355+
356+Two limits apply to a `/messages` request, independently of the per-blob cap:
357+
358+- **Batch count** — at most 100 messages per request (`413 batch_too_large`).
359+- **Total request body** — the runtime enforces a single HTTP body-size cap (`--http-max-body-bytes`, returning `413` with no JSON error code). Operators **must** set this above `maxBlobLen` plus the JSON envelope — the reference deployment uses `42M` — otherwise the runtime's default 16 MiB cap would reject a maximum-size blob before the per-blob `400` check is reached. Because this caps the whole body, a batch cannot smuggle 100 × 40 MiB: the total payload across all messages in one request is bounded by this cap.
280360
281361---
282362
@@ -296,18 +376,20 @@ When a client connects, it proves ownership of its keypair:
296376 ```json
297377 { "challenge": "<random_hex>", "expiresAt": 1679003660000 }
298378 ```
379+ The challenge is a lowercase hex string of at least 32 chars (128 bits of entropy). The reference server uses 48 hex chars (192 bits). The challenge MUST be unique per challenge request and consumed on first use (verified).
299380
300−3. Client signs the **UTF-8 bytes of the challenge string** with its Ed25519 private key and submits:
381+3. Client signs the **UTF-8 bytes of `"skrepka-auth-v1:" + server_host + ":" + challenge`** with its Ed25519 private key and submits. `server_host` is the bare lowercased hostname of the server the client dialed (no scheme, no port, no trailing dot). For IPv6 literals, the host must include square brackets (e.g. `[::1]`), matching the URL host syntax — the server's `serverHost` configuration must also include brackets for IPv6. The `skrepka-auth-v1:` prefix is a domain-separation tag distinguishing auth signatures from message signatures (which begin with `"skrepka-msg-v2:"` and sign `recipientPub ++ compressed`, §3):
301382 ```
302383 POST /auth/verify
303384 {
304385 "pubkey": "<hex(ed25519_pubkey)>",
305386 "challenge": "<random_hex>",
306− "signature": "<hex(ed25519_sign(private_key, utf8(challenge)))>",
387+ "signature": "<hex(ed25519_sign(private_key, utf8(\"skrepka-auth-v1:\" + server_host + \":\" + challenge)))>",
307388 "revokeOthers": false
308389 }
309390 ```
310− Setting `revokeOthers: true` invalidates every other live session for this pubkey (useful for "log out other devices").
391+ The server verifies against its own configured hostname (`serverHost`, port stripped). Binding the signature to the target host prevents a malicious or relay server from forwarding a challenge it obtained from a *different* server and replaying the client's signature there to impersonate the client (§10, *Unauthenticated session relay*).
392+ Setting `revokeOthers: true` invalidates every other live session for this pubkey (useful for "log out other devices"). Revocation is **immediate** (within the atomic block that processes the verify) and a revoked session receives `401 unauthorized` on its next request. Note that `revokeOthers` revokes **sessions only**, not outstanding challenges — a challenge issued before the `revokeOthers` call remains valid for its 60-second window. (The reference implementation has been fixed to also drop outstanding challenges for the pubkey on `revokeOthers: true`; independent implementations SHOULD do the same.)
311393
3123944. Server verifies the signature. On success, returns a session token valid for **1 hour**:
313395 ```json
@@ -319,10 +401,14 @@ The session token is included in subsequent authenticated requests as a bearer t
319401Authorization: Bearer <session_token>
320402```
321403
322−Sessions are also bound to the client's source IP (read from `X-Forwarded-For` when the server runs behind a reverse proxy). A request from a different IP is rejected as unauthorized.
404+When the server trusts proxy headers (`trustForwardedFor`, on by default), sessions are bound to the client's source IP — the last `X-Forwarded-For` hop. A later request from a different IP is rejected as `unauthorized`. If proxy-header trust is disabled, or no `X-Forwarded-For` header is present, the session is not IP-bound and this check is skipped.
405+
406+> **IP binding and mobile clients.** IP binding means a session does not survive a change of source address. In the reference deployment (Caddy in front, `trustForwardedFor` on) a phone that moves between Wi-Fi and cellular — or is NAT-rebound by its carrier — comes back on a new IP, so its next `/poll` or `/messages` gets `401 unauthorized` even though the token has not expired. This is expected: a client MUST treat `401` on any authenticated request as "re-run challenge/verify", not as a fatal error, and retry the request with the fresh token. The cost is one extra round trip per network change; the benefit is that a stolen bearer token is not usable from another network. Operators who prefer sessions that roam can run with `--trustForwardedFor=False`, which leaves sessions un-bound (the check is skipped when no trusted IP is available).
323407
324408When a token expires, the server responds with `401`. The client re-authenticates by repeating the challenge-response flow.
325409
410+> **Channel binding.** The signed payload includes the target server's hostname under the `skrepka-auth-v1:` domain-separation tag (step 3). Because the client binds to the host it actually dialed and the server verifies against its own `serverHost`, a relay server cannot forward a challenge it obtained from a *different* server and replay the resulting signature to impersonate the client there: the client's signature commits to the relay's hostname, not the victim server's, so verification at the victim fails. This binding is a **breaking change** — client and server must agree on the signed payload. The host comparison is on the bare hostname only (port and scheme excluded), so the same identity authenticates regardless of the port a server listens on.
411+
326412A successful `/auth/verify` also installs the pubkey in the local presence table and broadcasts an `online` gossip event to known peers (§7).
327413
328414### Error Responses
@@ -333,15 +419,20 @@ Errors return a non-2xx HTTP status with a body of:
333419{ "error": "<code>" }
334420```
335421
336−| HTTP Status | Error Code | Meaning |
337−|-------------|----------------------|--------------------------------------|
338−| 400 | `self_send` | A message in `/messages` is addressed to the sender |
339−| 400 | `invalid_request` | Malformed federation request |
340−| 401 | `unauthorized` | Missing, invalid, or expired token, or wrong IP |
341−| 404 | `no_presence` | Federated forward arrived for a recipient with no live local session |
342−| 413 | (HTTP only) | Encrypted blob exceeds limit |
343−| 429 | (HTTP only) | Per-route rate limit exceeded |
344−| 503 | `capacity` | Server-side resource cap reached |
422+| HTTP Status | Error Code | Meaning |
423+|-------------|-----------------------|--------------------------------------|
424+| 400 | `self_send` | A message in `/messages` is addressed to the sender |
425+| 400 | `invalid_message` | A message in `/messages` has a malformed `to` or oversized/invalid `encryptedBlob` |
426+| 400 | `invalid_request` | Malformed federation request: `fromServer` equals this server, fails the SSRF deny-list, or the event count exceeds the cap |
427+| 401 | `invalid` | `/auth/verify` failed (bad signature, or an expired/wrong-IP challenge) |
428+| 401 | `unauthorized` | `/poll` or `/messages` with a missing, invalid, or expired token, or a request from a different IP |
429+| 403 | `federation_disabled` | A `/federation/*` endpoint was called while federation is disabled |
430+| 404 | `no_presence` | Federated forward arrived for a recipient with no live local session |
431+| 413 | `batch_too_large` | A `/messages` batch exceeds the 100-message cap. A bare `413` with no JSON body (the runtime's HTTP body-size cap, `--http-max-body-bytes`) is also possible when the total request body exceeds the operator-configured limit; clients MUST treat a bodyless `413` the same as `batch_too_large` for retry purposes. Operators **must** set `--http-max-body-bytes` above `maxBlobLen` plus the JSON envelope (the reference deployment uses `42M`), otherwise the runtime's default 16 MiB cap rejects a maximum-size blob before the per-blob `400` check is reached. |
432+| 429 | (HTTP only) | Per-route rate limit exceeded |
433+| 503 | `capacity` | Server-side resource cap reached |
434+
435+> **Timing side-channel on token comparison (known gap).** The reference server compares the presented bearer token against each stored session token with plain string equality, which short-circuits on the first differing byte and so leaks, through response timing, how long a correct prefix an attacker guessed. The 192-bit token and the per-IP rate limit on `/poll` and `/messages` bound the attack — a blind guess is infeasible, and the sample count needed to resolve a per-byte timing difference across a network is large — but they do not eliminate it. A constant-time comparison is the correct fix; the reference implementation is blocked on the absence of such a primitive in its runtime. Implementations on platforms that offer one (`crypto_verify`, `subtle`, `hmac.compare_digest`, …) SHOULD use it.
345436
346437### `POST /poll` — Long poll for new events
347438
@@ -354,7 +445,7 @@ The client makes a blocking POST. The server holds the connection open until new
354445{ "cursor": 0 }
355446```
356447
357−`cursor` is the maximum `receivedAt` value the client has already seen (`0` on first poll).
448+`cursor` is the maximum `receivedAt` value the client has already seen. It MUST be a non-negative integer. `0` means "first poll" — no messages have been acked, so the server returns all pending events. Negative cursors are clamped to `0` by the server.
358449
359450**Response:**
360451```json
@@ -366,7 +457,8 @@ The client makes a blocking POST. The server holds the connection open until new
366457}
367458```
368459
369−- `cursor` is a server-side monotonic millisecond timestamp.
460+- `cursor` is a server-side **strictly-monotonic sequence**, seeded from the wall-clock millisecond at assignment but bumped to `previous + 1` whenever messages arrive within the same millisecond. Two messages therefore never share a `receivedAt`, so advancing the cursor past one message can never silently drop another. Treat it as an opaque checkpoint, not a clock.
461+- The server clamps the supplied cursor to `max(0, min(max(t, curSeq), cursor))`, where `t` is the current wall-clock millisecond and `curSeq` is the last assigned sequence — note the ceiling is `max(t, curSeq)`, not just `curSeq`, so under idle the clamp allows acking up to the current wall-clock, while under sustained bursts (where the sequence has run ahead of the clock) it still refuses to ack beyond what has actually been issued.
370462- Advancing past a message acts as an **implicit ack**: when the next poll arrives with a `cursor` greater than or equal to a stored message's `receivedAt`, the server drops the stored message **and any pending federation forwards for it**.
371463- If events are available immediately, the server responds right away. Otherwise it parks the connection on a row-level wait against the mailbox and returns when a new matching message lands or after 25 s, whichever comes first.
372464- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
@@ -412,13 +504,29 @@ The following endpoints exist in earlier drafts of this spec but are **not** imp
412504
413505### Mesh Formation
414506
415−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.
507+Federation is open and **off by default** (`federationEnabled = False`); an operator opts in explicitly. There is no central registry.
508+
509+A server joins the mesh in two steps:
510+
511+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.
512+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.
513+
514+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.
515+
516+```sh
517+# relay A, seeded with relay B — B needs no seed of its own to reach A
518+skrepka-server --serverHost=a.example.com --federationEnabled=true --seedPeers=b.example.com
519+```
520+
521+> **Implementation note (reference server).** `serverHost`, `federationEnabled` and `seedPeers` are configuration, and the reference relay exposes them as `--name=value` settings. As of Knot `2026.6.26.1947` the runtime *accepts but silently ignores* these flags at startup — they currently take effect only when passed to `knot build` — so a relay configured purely on the command line will run with the compiled-in defaults (`localhost`, federation off, no seeds) and quietly fail to federate. Verify with the startup log, which prints the federation state and seed list it actually loaded.
416522
417523Operators may apply an inbound blocklist; outbound federation is filtered through a built-in **SSRF deny-list** that rejects:
418524
419−- `localhost`, IP-literal forms (`127.0.0.0/8`, `10/8`, `192.168/16`, `169.254/16`, `0/8`)
420−- IPv6 literals (`::`, `::1`, bracketed `[…]` syntax)
421−- `.localhost`, `.local`, `.internal`, `.arpa`, `.onion` suffixes
525+- `localhost`, and the `.localhost`, `.local`, `.internal`, `.arpa`, `.onion` suffixes
526+- Private and special-use IPv4 ranges: `0/8`, `10/8`, `100.64/10` (CGNAT), `127.0.0.0/8`, `169.254/16` (link-local), `172.16/12`, `192.0.2/24` and `203.0.113/24` (TEST-NET), `192.168/16`, `198.18/15` (benchmarking), and everything from `224/4` upward (multicast + reserved)
527+- Obfuscated IPv4 literals: bare integer/"dword" form (e.g. `2130706433`), `0x`-prefixed hex, and dotted forms with a leading-zero (octal) octet (e.g. `0177.0.0.1`)
528+- IPv6 literals — any host containing more than one `:` (covering `::`, `::1`, link-local, ULA, and global addresses) and any bracketed `[…]` form
529+- The empty string, and any host beginning with `.` or `:`
422530- Anything containing characters outside `[a-z0-9-.:]`
423531
424532This applies both to outbound `fetch` calls and to the `fromServer` field on inbound federation requests.
@@ -457,6 +565,8 @@ A server may hold multiple presence rows for the same pubkey — one per peer th
457565- Rejected with `400 invalid_request` if `fromServer` equals this server's identity, fails the SSRF deny-list, or the event count exceeds the cap.
458566- `online` events for keys not previously seen at `fromServer` trigger an immediate forward sweep: any locally-queued messages addressed to those keys are scheduled for `/federation/forward`.
459567
568+> **Unauthenticated — and this endpoint steers delivery.** `/federation/gossip` accepts any caller, and the `fromServer` field is self-asserted. Because an `online` event causes the receiving server to forward that pubkey's queued mail to `fromServer`, anyone who can reach a relay can *redirect* a victim's inbound ciphertext to a host of their choosing by announcing `{ "eventType": "online", "pubkey": <victim>, "fromServer": <attacker-host> }`. See §10, *Open federation — gossip redirect*.
569+
460570### `POST /federation/forward` — Receive a federated message
461571
462572**Request:**
@@ -471,6 +581,8 @@ There is no `from`, no origin-server hint, and no sender timestamp — the recei
471581
472582The receiver **requires the recipient to be currently locally online** (i.e. have an unexpired local presence row). If not, it returns `404 no_presence` and the origin retries (see below) or holds the message in its own mailbox.
473583
584+> **Unauthenticated — mailbox injection is possible.** `/federation/forward` accepts any caller: there is no bearer token, no peer signature, and nothing ties the request to a server the receiver actually federates with. Any host that can reach a relay can therefore inject arbitrary blobs into the mailbox of any recipient who is currently locally online. The only bounds are the per-source-IP rate limit on the route (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients). Injected junk is undecryptable, so a client drops it silently on ingest — but it still occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. This is an accepted limitation of v0.1; see §10, *Open federation — forward injection*. Peer authentication is the intended fix.
585+
474586### Forward Retries
475587
476588Forwards that fail (network error or `no_presence`) remain queued at the origin and are retried by a background loop:
@@ -542,7 +654,7 @@ Note: Alice does not need to stay online for steps 4–9. Her server handles fed
542654
543655### Message Ordering
544656
545−Message ordering is **best-effort** and guaranteed only within a single server. Each server's `receivedAt` provides strict ordering for that server's mailbox, and the `cursor` returned by `/poll` reflects this sequence.
657+Message ordering is **best-effort** and guaranteed only within a single server. Each server assigns every message a strictly-monotonic `receivedAt` sequence (unique per message, never colliding even under bursts within one millisecond), which provides total ordering for that server's mailbox; the `cursor` returned by `/poll` reflects this sequence.
546658
547659Across federated servers, messages may arrive out of order due to network latency, retry delays, or differing reception times. Clients SHOULD use the sender-provided `ts` field (from the decrypted plaintext payload, §4) for display ordering and treat `cursor` as a polling checkpoint only, not a global ordering guarantee.
548660
@@ -561,19 +673,60 @@ Skrepka protects message **content and sender identity** from servers and networ
561673| Message confidentiality | E2E encryption (X25519 + HKDF-SHA256 + XChaCha20-Poly1305) |
562674| Sender authenticity | Ed25519 signature on every message |
563675| Sender anonymity vs. server | Sender pubkey is inside the AEAD ciphertext — servers see the recipient but not the sender |
564−| Replay protection | Unique nonce per message; signature binds the recipient pubkey, so a captured blob cannot be replayed against a different recipient |
565−| 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 |
676+| Replay protection (cross-recipient) | Unique nonce per message; signature binds the recipient pubkey, so a captured blob cannot be replayed against a *different* recipient. This does **not** stop re-delivery of the same blob to the *same* recipient — see *Same-recipient replay* below |
677+| Deletion-dependent recovery (**not** post-compromise security) | Each message uses a fresh ephemeral X25519 key, so there is no shared long-term session secret to steal. But this buys **nothing cryptographic** against a later key compromise: the recipient's long-term key still opens *any* blob an attacker has kept a copy of. The only thing protecting an old message is that the ciphertext no longer exists — relays delete on ack/TTL, and clients delete on request. That is a *deletion* property, not a *forward secrecy* property, and it fails completely against anyone who recorded the traffic. See *No forward secrecy* below, which is the same fact stated as a risk. |
566678
567679### What Is NOT Protected
568680
569681| Risk | Details |
570682|-----------------------------------|-----------------------------------------------------|
571−| **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. |
572−| **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. |
573−| **Presence gossip leaks location** | Federated peers learn which server a user is currently connected to. |
683+| **No forward secrecy** | A captured blob plus a later compromise of the recipient's long-term key **is enough to decrypt that message** — the per-message ephemeral key is the *sender's*, and the recipient's half of the exchange is their long-term identity key, which never rotates. So an adversary who records ciphertext today (a network observer, or any relay it passed through) and obtains the recipient's key at any point in the future can read everything it recorded. There is no ratchet, so there is also no post-compromise recovery: once the key leaks, future messages are readable too until the user rotates identity. Do not read the per-message ephemeral keys as forward secrecy; they are not. **Additionally**, because the HKDF salt (`ephemeral_public ‖ recipient_x25519_public`) omits the sender's identity, compromising the recipient's private key also enables a *key-compromise impersonation (KCI)* vector: the attacker can decrypt a captured blob, replace the sender identity and signature inside the inner buffer with their own, re-encrypt with the same derived key, and re-deliver. The recipient sees a valid message apparently from a different sender. Including the sender pubkey in the HKDF salt or info would close this by construction, but requires a wire-format change. The signature already prevents forgery *without* key compromise, so KCI is an additional consequence of an already-catastrophic key leak, not a standalone attack. |
684+| **No AAD on AEAD — envelope fields unauthenticated** (v0.1, fixed in v0.2) | In v0.1, XChaCha20-Poly1305 was called with no associated data. The envelope's `to` field was not authenticated by the AEAD — a relay could swap `to` to a different recipient's pubkey, silently misdelivering and censoring messages with zero detectability. **v0.2 fixes this**: the AAD includes `version_byte ‖ ephemeral_public ‖ nonce ‖ recipient_ed25519_public`, binding the envelope to the ciphertext. A `to`-swap now causes AEAD failure at the wrong recipient, and the original recipient's message stays in the mailbox. |
685+| **Recipient metadata exposure** | Servers see the recipient pubkey and arrival time of every message, plus the rough size of each blob. They do **not** see the sender. **The relay can also infer delivery** by correlating a message's arrival time with the recipient's subsequent poll time — the gap between the two reveals whether the recipient was online and actively reading. |
686+| **Blob size reveals message type** | The length-padding buckets (§3, *Length Padding*) are coarse enough that a relay can distinguish payload types: a `delivery.ack` with a single ID (~233 bytes) falls in the 256-byte bucket, a short `text` message falls in the 512-byte bucket, and a `profile` with a photo falls in the 65536-byte bucket. A relay observing the bucket pattern learns who acks whom and who updates their profile — without decrypting anything. Finer-grained padding (e.g., a single minimum bucket for all small messages) would reduce this leak. |
687+| **Poll cursor reveals reading progress** | The client sends its cursor (last-seen `receivedAt`) on every poll, telling the relay exactly how far behind the recipient is. A relay can determine whether the recipient is actively reading (cursor advances each poll) or just polling (cursor stays the same), estimate reading speed, and detect device switches (cursor jumps backward). This is inherent to the long-poll model. |
688+| **CRIME/BREACH surface on profiles** | The CRIME/BREACH side channel (§3, *Length Padding*) extends beyond text messages to `profile` broadcasts: `display_name`, `bio`, and `photo` are compressed together in one zstd frame. If an attacker can influence part of the profile text (e.g., the user sets their display name based on something the attacker controls), the compressed size could leak information about the user's other profile fields. The risk is low in practice — the base64-encoded photo is high-entropy and nearly incompressible, and bucket padding coarsens the granularity — but the spec's claim that "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand" is too strong for profile broadcasts. |
689+| **Presence gossip leaks location** | Federated peers learn which server a user is currently connected to. A presence row is anchored for up to 90 min (`onlineGossipTtl`), which outlives the 1 h session, so location metadata — and forwards aimed at the user — can linger for up to ~90 min after the user disconnects. |
574690| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
575−| **Open federation** | Federation endpoints are unauthenticated. The SSRF deny-list and `no_presence` gate limit abuse but do not authenticate peers; a hostile server can join the mesh and observe gossip. |
576−| **TOFU only** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band to detect MITM at first contact. |
691+| **Mailbox eviction under flood (DoS)** | The per-recipient mailbox cap (`maxMessagesPerRecipient`, default 10 000) evicts the oldest messages when the cap is reached. An authenticated attacker (pubkeys are free to mint) can flood a victim's mailbox with junk, pushing out genuine undelivered messages. The victim's client only sees junk; the genuine messages are permanently destroyed. At 60 requests/min × 100 messages = 6 000/min, the attacker fills the cap in ~2 minutes (per IP; multiple IPs are faster). This is an accepted design tradeoff of v0.1 — a real fix requires per-sender quotas or pricing. |
692+| **Unbounded mailbox storage** | The per-recipient mailbox cap (`maxMessagesPerRecipient`) bounds each recipient but not the count of distinct recipient keys. An attacker can fill the server's disk by sending messages to many distinct invalid pubkeys, bounded only by the 30-day TTL and rate limits. The reference server now enforces a global `maxMessagesTotal` cap (default 1 000 000) that evicts the globally-oldest rows on overflow, symmetric with the other state refs. |
693+| **CPU amplification via dedup scan** | `appendMessage` performs an O(n) full-scan dedup check on every message (§6). Under a burst of 100-message batches, each message triggers a scan of the entire mailbox. With large mailboxes this is quadratic and amplifies CPU cost per request. Bounded by rate limits (60/min per IP) and the fact that the attacker must be authenticated, but the amplification is real. |
694+| **Clock forward-jump pins the sequence counter** | The `receivedAt` sequence counter is a forward-only ratchet: `max(wall_clock, previous + 1)`. A single NTP forward-jump (server migration, misconfiguration) pins the counter at the jumped value, delaying all TTL pruning until real time catches up. Backward jumps are safe (the counter dominates). |
695+| **Challenge pool floodable (soft DoS)** | The challenge pool is globally capped at `maxChallenges` (default 10 000) with per-IP rate limiting. An attacker with many IPs — or behind a proxy with XFF spoofing when `trustForwardedFor = true` — can fill the pool with challenges for random pubkeys, causing legitimate challenges to be LRU-evicted. Authentication still works but with extra latency (clients retry). This is a soft DoS. |
696+| **Rate limit counts requests, not messages** | The per-IP send rate limit (60/min) counts requests, and each request can carry up to 100 messages. The effective message rate is therefore 60 × 100 = 6 000 messages/min per IP (bounded by the body-size cap), much higher than the per-request limit suggests. An attacker with multiple IPs can send millions of messages per minute. |
697+| **Session pool Sybil-floodable** | The global session pool (`maxSessions`, default 10 000) uses LRU eviction. An attacker with enough IPs can generate thousands of keypairs, authenticate each one, and fill the pool — evicting legitimate users' sessions. Users re-authenticate automatically (they get `401` and retry), so this is a soft DoS adding latency and load. Per-IP session caps were considered and rejected because they collapse behind carrier-grade NAT. |
698+| **Presence table fillable with fake keys** | The presence table (`maxPresence`, default 100 000) can be filled with fake keys via gossip (100 events per request, 60/min per IP). Local rows are exempt from the cap, so local users' presence is never evicted, but legitimate *remote* presence (from honest peers) can be evicted, causing forwards to those peers to fail. Requires federation to be enabled. |
699+| **Gossip amplification** | A single `/federation/gossip` request with 100 `online` events triggers up to 100 mailbox scans and forward-staging operations. The reference server caps the number of newly-online keys that trigger forward sweeps per batch (`maxNewlyOnlineForwards`, default 10), limiting the amplification to 10× per request. An attacker can still send 100 online events per request, but only 10 trigger forward sweeps; the remaining keys are written to `*presence` without a forward sweep. Bounded by the per-IP rate limit (60/min). |
700+| **`fromServer` not verified** | The `fromServer` field in gossip events is self-asserted and not verified against the connecting peer's actual network address — the server has **no mechanism** to verify that the connecting peer is who it claims to be. An attacker can claim `fromServer: "legit-relay.example.com"` from any IP, poisoning the presence table with the legitimate relay's hostname. Forwards go to the legitimate relay (which doesn't have the key online → 404), causing spurious forward requests. This is a variant of the gossip-redirect attack; the intended fix is reverse-DNS verification of the connecting peer, which requires runtime DNS support not currently available. |
701+| **No identity revocation** | There is no in-protocol mechanism to revoke or invalidate a compromised identity key. A stolen key allows full takeover: decrypt all captured messages, send messages as the user, poll their mailbox, and receive their messages via presence redirect. The only defense is out-of-band communication. See §11 (Future Extensions — Key Rotation) for the intended direction. |
702+| **Unauthenticated session relay** (mitigated) | The auth signature is bound to the target server's hostname under a domain-separation tag (§6), so a malicious/relay server can no longer forward another server's challenge and replay the resulting signature to impersonate the client elsewhere. Residual caveat: the binding is to the hostname the client dialed, so it assumes the client reaches each server under its true `serverHost` (DNS/TLS integrity). |
703+| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. The damage is bounded per payload type: `text` is deduplicated by `id` (the reference client maintains a persistent `seen_ids` set that survives `trim_history`), `delivery.ack` is idempotent, and `profile` is guarded by the per-contact `ts` staleness check of §4. |
704+| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated; an attacker announces the victim online at a host it controls and origin relays forward the victim's queued ciphertext there. **Active users are also affected**: even when the recipient is locally online and actively polling, a forged remote presence row causes each new incoming message to be duplicated to the attacker's server — the victim receives everything normally but the attacker silently collects ciphertext metadata for the live inbound stream. See *Open federation* below. |
705+| **Open federation — forward injection** | `/federation/forward` is unauthenticated; any host can inject arbitrary blobs into a currently-online recipient's mailbox. See *Open federation* below. |
706+| **Bearer token theft = full impersonation** | Sessions are bearer-token-based: anyone who obtains the token can use it. The token is bound to `(pubkey, ip, expiry)`, but the `pubkey` binding is for authorization (self-send rejection), not for authenticating the request. If an attacker steals a bearer token (e.g., from a compromised proxy's access log), they can poll the victim's mailbox and send messages as the victim — modulo IP binding. Token expiry (1 hour) is the only automatic recovery. Consider shorter token lifetimes or per-request signing. |
707+| **Token entropy depends on runtime CSPRNG** | Session and challenge tokens are generated by the server's runtime random source. Implementations SHOULD verify that their RNG is cryptographically secure (e.g., backed by `/dev/urandom` or an OS-provided CSPRNG). |
708+| **Non-curve-point `to` keys accepted** | The server validates that `to` is 64 lowercase hex chars but does not check that the decoded bytes form a valid Ed25519 curve point. Blobs addressed to non-existent keys are stored and forwarded normally, consuming storage until TTL expiry. |
709+| **No crypto rotation performed** | The wire format carries a version byte (§3, *Cryptographic Versioning*) and a fixed HKDF `info` (`"skrepka-v2"`), so a future revision *can* introduce new AEAD/KDF/curve choices by bumping both. Version 0x02 added AEAD associated data (AAD) to bind envelope fields to the ciphertext. No further rotation has been performed. |
710+| **TOFU only — no verification UI** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band (e.g., @p syllables in person) to detect MITM at first contact. The reference client does not yet provide a UI for this out-of-band verification. Independent clients SHOULD implement such a UI. |
711+
712+### Open Federation (accepted limitation of v0.1)
713+
714+Every `/federation/*` endpoint accepts **any caller**. There is no bearer token, no peer signature, and no allow-list: a relay cannot tell a genuine peer from an arbitrary host on the internet. Two concrete attacks follow, and it is worth stating them precisely, because "a hostile server can observe gossip" understates both.
715+
716+**1. Gossip redirect — actively rerouting a victim's mail.** The `fromServer` field on `/federation/gossip` is self-asserted, and an `online` event for a previously-unseen key makes the receiving relay sweep its mailbox and forward that key's queued messages to `fromServer` (§7). So an attacker does not have to wait and watch. It posts:
717+
718+```json
719+{ "events": [ { "eventType": "online", "pubkey": "<victim>" } ],
720+ "fromServer": "attacker.example.com" }
721+```
722+
723+to every relay it can reach. Each relay holding mail for the victim now forwards that ciphertext to the attacker's host. The receiver-side "recipient must be locally online" gate does not help — that check runs on the *attacker's* server, which the attacker controls. Message **content** stays protected by E2E encryption, but the attacker harvests ciphertext copies and recipient/timing/size metadata — a real leak for a metadata-privacy-focused messenger.
724+
725+**2. Forward injection — spam into an online recipient's mailbox.** `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into the mailbox of any recipient who is currently locally online. Injected junk is undecryptable, so a client drops it silently on ingest — but it occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. The only bounds are the per-source-IP rate limit (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients).
726+
727+**3. Forward cancellation — deleting queued mail-in-flight.** The offline-event forward cancellation (§7) **widens** the unauthenticated-gossip blast radius from writing presence to **deleting queued forwards** — an attacker claiming `fromServer: X` can cancel pending forwards to any real relay X by announcing an `offline` event for the relevant key, so mail queued for delivery to X is dropped from the origin's outbox (the message itself stays in `*messages` and is re-forwarded if the key reappears on another peer, so this delays rather than destroys delivery).
728+
729+**Mitigation path.** Both attacks are accepted limitations of v0.1's unauthenticated federation. The intended fix is peer authentication — signed presence gossip and authenticated forward requests — or an explicit peer allow-list gate before `forwardQueuedToServer`. Until then, operators who federate only with trusted peers (or not at all) are unaffected.
577730
578731### Recommendations for Implementers
579732
@@ -582,3 +735,33 @@ Skrepka protects message **content and sender identity** from servers and networ
582735- Consider pinning server TLS certificates for additional transport security.
583736- Federate only with peers operated by trusted operators if presence metadata is sensitive.
584737- Servers SHOULD delete message blobs as soon as the implicit ack (cursor advance) lands.
738+
739+---
740+
741+## 11. Future Extensions
742+
743+Nothing in this section is implemented or normative in v0.1. It records the shape the extension is expected to take, so that clients avoid design choices that would foreclose it.
744+
745+### Key Rotation
746+
747+Identity in Skrepka *is* the Ed25519 keypair (§2), so today a user who loses or wants to retire a key has no in-protocol way to say so: they have to re-share a new key out-of-band, and every contact must re-add them by hand. Worse, because contacts are TOFU (§2, *Trust Model*), a contact who simply receives a message from an unfamiliar key cannot distinguish "my peer rotated" from "someone is impersonating my peer".
748+
749+A future revision may add a `key_rotation` message type (§4) that lets a user signal the change in-band:
750+
751+```json
752+{
753+ "type": "key_rotation",
754+ "new_pubkey": "<hex(new_ed25519_pubkey)>",
755+ "ts": 1679000000000
756+}
757+```
758+
759+The message is a normal plaintext payload: encrypted to each contact and, per §4, signed by the **old** key — which is exactly the point. Only the holder of the retiring key can announce its successor, so the announcement carries the same authority as any other message from that identity, and a recipient who already trusts the old key can extend that trust to the new one without a fresh out-of-band fingerprint check. Recipients would update the contact entry in place: replace the stored pubkey with `new_pubkey`, keep the existing display name, avatar, and message history, and retain the old key only as a *former identity* so that already-received messages still verify.
760+
761+Open questions a full specification must settle:
762+
763+- **Proof of possession of the new key.** A signature by the old key alone proves the *announcement* is authentic, not that the announcer holds the new secret. A cross-signature (the payload additionally signed by the new key, over both pubkeys) closes that gap and should be considered mandatory.
764+- **Ordering and replay.** Rotations must be totally ordered per identity, or a replayed old `key_rotation` could roll a contact back to a superseded key. The per-contact `ts` staleness rule of §4 is the obvious mechanism, and it inherits that rule's weakness (§10, *Same-recipient replay*) — a monotonic rotation counter is likely the safer construction.
765+- **Contacts who miss the announcement.** A contact offline past the 30-day TTL (§9) never receives the rotation and is left holding a dead key. Re-announcing on reconnect, or letting a client ask a peer to re-send its current identity, would need to be specified.
766+- **Revocation vs. rotation.** These are different operations. Rotation says "use this key from now on" and assumes the old key is still under the owner's control; revocation says "the old key is compromised — distrust it", and an attacker holding the compromised key can forge exactly the rotation message that would hijack the identity. A rotation announcement therefore MUST NOT be treated as a compromise recovery mechanism. Recovering from a stolen key needs a separate design (an offline recovery key, or out-of-band re-verification) and is out of scope here.
767+- **No retroactive protection.** Rotating does not protect past traffic. Messages already sent to the old key remain decryptable by whoever holds it, exactly as §10, *No forward secrecy* describes.
README.md+37−11
@@ -1,25 +1,51 @@
11# Skrepka
22
3−<p align="center">
4− <img src="client/Assets/Images/logo.png" width="300px" align="center">
5−</p>
3+Decentralized, end-to-end encrypted messaging with no accounts and no home servers. Identity is an Ed25519 keypair; messages route over plain HTTP through any relay in an open federated mesh. A relay only ever sees the recipient's public key and an opaque blob — the sender's identity and signature live *inside* the AEAD ciphertext, so a server never learns who sent a message.
64
7−Decentralized, end-to-end encrypted messaging with no accounts and no home servers. Identity is a keypair; messages route over plain HTTP through any relay in the mesh. See [PROTOCOL.md](PROTOCOL.md) for the full specification.
5+See [PROTOCOL.md](PROTOCOL.md) for the full specification.
86
9−## Run the client
7+## What's here
108
11−The client is an F# / .NET 10 / Fabulous + Avalonia app targeting **macOS desktop** and **iOS**.
9+| Path | What it is |
10+| --- | --- |
11+| `core/` | The shared core (Rust, [Crux](https://redbadger.github.io/crux/)). All business logic: identity, crypto, auth, the long-poll loop, the outbox, contacts, profiles, message ingest. |
12+| `apple/` | The iOS client (Swift + SwiftUI, iOS 17+). A thin shell: it executes HTTP/key-value/timer effects and renders the view model. No business logic. |
13+| `server.knot` | The relay server, written in [Knot](https://github.com/ilyakooo0/knot) and compiled to a native binary. |
14+| `servers.json` | A public relay list. A static suggestion, not a directory. |
15+
16+Crypto is X25519 + XChaCha20-Poly1305 with HKDF-SHA256, signed with Ed25519 — see `core/src/crypto.rs`, which is written to be auditable against the spec.
17+
18+## Build the client
19+
20+One-time setup:
1221
1322```sh
14−cd client && ./run-mac.sh # macOS desktop (.app bundle)
15−cd client && ./run-ios.sh # iOS simulator (default: iPhone 17 Pro)
16−cd client && dotnet build # Plain build
23+rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
24+cargo install boltffi_cli
25+brew install xcodegen
1726```
1827
19−## Install the relay server
28+Then (`just` alone runs `test`):
29+
30+```sh
31+just test # run the core test suite
32+just generate # regenerate Swift types, xcframework, and the Xcode project
33+just build SIM_ID=<udid> # build for a booted simulator
34+just run SIM_ID=<udid> # install + launch on that simulator
35+```
36+
37+Run `just generate` after any change to the core's `Event`, `ViewModel`, or `Effect` types — otherwise the Swift side compiles against stale generated types.
38+
39+## Run a relay
40+
41+To install one on a Linux host, with a systemd service and [Caddy](https://caddyserver.com) as a reverse proxy with automatic TLS:
2042
2143```sh
2244curl -fsSL https://raw.githubusercontent.com/ilyakooo0/skrepka/master/install.sh | sudo DOMAIN=skrepka.example.com sh
2345```
2446
25−This installs the server binary, sets up a systemd service, and configures [Caddy](https://caddyserver.com) as a reverse proxy with automatic TLS. Re-running the same command updates the binary and restarts the service.
47+Re-running the same command updates the binary and restarts the service.
48+
49+Every top-level constant in `server.knot` is overridable at startup as `--name=value` (`--serverHost=`, `--federationEnabled=False`, `--trustForwardedFor=False`, …); run the binary with `--help` for the full list. `--serverHost` must be the public hostname clients use to reach the relay: it is the federation identity, and clients bind their auth signature to it.
50+
51+To run a relay locally you need a prebuilt `./server` binary (`knot build server.knot`, or grab a release asset); then `just server` resets the disposable database and starts it.
apple/SkrepkaApp/Core.swiftnew file+98
@@ -0,0 +1,98 @@
1+import Foundation
2+import Skrepka
3+import SkrepkaShared
4+
5+/// Drives the Rust core: serializes events in, deserializes effects out, performs
6+/// each effect (HTTP, key-value, timer, render) and resolves it back into the core.
7+@MainActor
8+final class Core: ObservableObject {
9+ /// One core per process, reachable outside the view tree.
10+ ///
11+ /// `SkrepkaApp` holds this in a `@StateObject`, but the background-refresh handler runs
12+ /// with no view installed — and a `@StateObject` read from there hands back a *fresh*
13+ /// instance every time. That would mean a second `CoreFFI` with its own identity, its own
14+ /// kv state and its own poll loop, writing over the real one's files and rendering into
15+ /// nothing. The state machine is a singleton in fact; make it one in the type.
16+ static let shared = Core()
17+
18+ @Published var view: ViewModel
19+
20+ /// A Keychain failure never reaches the core — without an identity there is no
21+ /// `IdentityLoaded` event to carry it — so the shell holds it. `RootView` shows this
22+ /// instead of spinning on a progress view forever.
23+ @Published private(set) var identityError: String?
24+
25+ private let core = CoreFFI()
26+ private let store = KvStore()
27+
28+ private init() {
29+ // The FFI's guard returns empty bytes when the core panics; force-try
30+ // on empty bytes crashes the app. Fall back to a default ViewModel instead.
31+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? ViewModel()
32+ bootIdentity()
33+ }
34+
35+ // MARK: - Event/effect loop
36+
37+ func update(_ event: Event) {
38+ guard let eventData = try? event.bincodeSerialize() else { return }
39+ let effects = [UInt8](core.update(data: Data(eventData)))
40+ dispatch(effects)
41+ }
42+
43+ private func resolve(_ id: UInt32, _ output: [UInt8]) {
44+ let effects = [UInt8](core.resolve(id: id, data: Data(output)))
45+ dispatch(effects)
46+ }
47+
48+ private func dispatch(_ effects: [UInt8]) {
49+ guard let requests = try? Requests.bincodeDeserialize(input: effects).value else { return }
50+ for request in requests {
51+ process(request)
52+ }
53+ }
54+
55+ private func process(_ request: Request) {
56+ let id = request.id
57+ switch request.effect {
58+ case .render:
59+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? self.view
60+
61+ case .http(let req):
62+ Task { [weak self] in
63+ let result = await Http.perform(req)
64+ guard let output = try? result.bincodeSerialize() else { return }
65+ self?.resolve(id, output)
66+ }
67+
68+ case .keyValue(let op):
69+ // The file I/O runs on the store's own serial queue; hop the result back to
70+ // the main actor, because the core is single-threaded and `.render` publishes.
71+ store.handle(op) { [weak self] result in
72+ guard let output = try? result.bincodeSerialize() else { return }
73+ Task { @MainActor in self?.resolve(id, output) }
74+ }
75+
76+ case .time(let req):
77+ Time.handle(req) { [weak self] response in
78+ guard let output = try? response.bincodeSerialize() else { return }
79+ self?.resolve(id, output)
80+ }
81+ }
82+ }
83+
84+ // MARK: - Identity
85+
86+ /// Loads the identity key, or reports why it could not. Safe to call again — that is
87+ /// what the retry affordance on the error screen does, and the common failure (the
88+ /// device has not been unlocked since boot) resolves on its own.
89+ func bootIdentity() {
90+ switch Keychain.loadOrCreateIdentity() {
91+ case .success(let key):
92+ identityError = nil
93+ update(.identityLoaded([UInt8](key)))
94+ case .failure(let error):
95+ identityError = error.message
96+ }
97+ }
98+}
apple/SkrepkaApp/Effects.swiftnew file+428
@@ -0,0 +1,428 @@
1+import CryptoKit
2+import Foundation
3+import Security
4+import Skrepka
5+
6+// MARK: - HTTP
7+
8+/// Hard cap on any HTTP response body we will buffer. A hostile relay
9+/// can answer any endpoint with a multi-gigabyte body; without this cap
10+/// `URLSession.data(for:)` would OOM the app before the core's own
11+/// `MAX_POLL_RESPONSE_BYTES` check (which runs on the wrong side of the
12+/// FFI boundary to prevent the allocation) ever fires.
13+private let maxResponseBytes = 64 * 1024 * 1024 // 64 MiB
14+
15+/// The one session delegate, handling two defenses against an untrusted relay.
16+///
17+/// **Redirect cancellation.** URLSession replays the original request's headers onto a
18+/// redirect target, including `Authorization: Bearer <session_token>`. A hostile (or
19+/// compromised) relay could answer `/poll` or `/messages` with a 302 to a host it chooses
20+/// and harvest the bearer token from the request we would dutifully re-send there. Sessions
21+/// are bound to a single relay by design (§6), so a cross-host redirect is never legitimate
22+/// — refusing it costs nothing and closes the token-exfiltration path. Same-host redirects
23+/// (e.g. HTTP → HTTPS upgrade on the same host) are allowed; the Authorization header stays
24+/// within the relay's own host. Returning `nil` from the completion handler does not error
25+/// the task: it stops the redirect and hands the 3xx response itself back to the caller,
26+/// which the core sees as a non-2xx status and treats as a failed request.
27+///
28+/// **Response size limiting.** As body chunks arrive we accumulate the byte count per task
29+/// and cancel the task the moment it crosses `maxResponseBytes`, so a multi-gigabyte body
30+/// never fully materializes in memory. Cancelling surfaces to the caller as a `URLError`
31+/// with code `.cancelled`, which `Http.perform` maps to a transport error.
32+private final class HttpSessionDelegate: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate {
33+ let maxResponseBytes: Int
34+
35+ /// Per-task accumulated byte counts. The delegate is shared across every concurrent
36+ /// request, so a single shared counter would conflate their bodies — key by task instead.
37+ /// Guarded by `lock` because delegate callbacks arrive on the session's own (non-main)
38+ /// queue and may interleave across tasks.
39+ private var taskSizes: [URLSessionTask: Int] = [:]
40+ private let lock = NSLock()
41+
42+ init(maxResponseBytes: Int) {
43+ self.maxResponseBytes = maxResponseBytes
44+ }
45+
46+ func urlSession(
47+ _ session: URLSession,
48+ task: URLSessionTask,
49+ willPerformHTTPRedirection response: HTTPURLResponse,
50+ newRequest request: URLRequest,
51+ completionHandler: @escaping (URLRequest?) -> Void
52+ ) {
53+ // Allow same-host redirects (e.g. HTTP → HTTPS upgrade on the same host).
54+ // Cancel cross-host redirects — a hostile relay could redirect to an
55+ // attacker-controlled host and harvest the Authorization header.
56+ if let originalURL = task.currentRequest?.url,
57+ let redirectHost = request.url?.host?.lowercased(),
58+ let originalHost = originalURL.host?.lowercased(),
59+ redirectHost == originalHost {
60+ completionHandler(request)
61+ } else {
62+ completionHandler(nil)
63+ }
64+ }
65+
66+ func urlSession(
67+ _ session: URLSession,
68+ dataTask: URLSessionDataTask,
69+ didReceive data: Data
70+ ) {
71+ lock.lock()
72+ let total = (taskSizes[dataTask] ?? 0) + data.count
73+ taskSizes[dataTask] = total
74+ let exceeded = total > maxResponseBytes
75+ lock.unlock()
76+ if exceeded {
77+ dataTask.cancel()
78+ }
79+ }
80+
81+ func urlSession(
82+ _ session: URLSession,
83+ task: URLSessionTask,
84+ didCompleteWithError error: Error?
85+ ) {
86+ lock.lock()
87+ taskSizes.removeValue(forKey: task)
88+ lock.unlock()
89+ }
90+}
91+
92+enum Http {
93+ /// Strongly held by `session` for the app's lifetime — the session is never invalidated,
94+ /// so the delegate is never released. That is the intended shape here, not a leak.
95+ private static let httpDelegate = HttpSessionDelegate(maxResponseBytes: maxResponseBytes)
96+
97+ /// A private, ephemeral session. `URLSession.shared` would hand every relay a
98+ /// persistent cookie jar and an on-disk response cache — a relay is untrusted
99+ /// infrastructure and must not be able to pin a stable identifier on the client
100+ /// or leave polled ciphertext lying around on disk. It would also follow redirects,
101+ /// which is what `HttpSessionDelegate` is here to prevent.
102+ private static let session: URLSession = {
103+ let config = URLSessionConfiguration.ephemeral
104+ config.httpCookieAcceptPolicy = .never
105+ config.httpShouldSetCookies = false
106+ config.httpCookieStorage = nil
107+ config.urlCache = nil
108+ config.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
109+ config.timeoutIntervalForRequest = 70 // server long-polls for ~25s
110+ config.tlsMinimumSupportedProtocolVersion = .TLSv12
111+ return URLSession(configuration: config, delegate: httpDelegate, delegateQueue: nil)
112+ }()
113+
114+ static func perform(_ req: HttpRequest) async -> HttpResult {
115+ guard let url = URL(string: req.url),
116+ let scheme = url.scheme,
117+ scheme == "http" || scheme == "https" else {
118+ return .err(.url(req.url))
119+ }
120+ var request = URLRequest(url: url)
121+ request.httpMethod = req.method
122+ request.timeoutInterval = 70 // server long-polls for ~25s
123+ for header in req.headers {
124+ request.setValue(header.value, forHTTPHeaderField: header.name)
125+ }
126+ if !req.body.isEmpty {
127+ request.httpBody = Data(req.body)
128+ }
129+ do {
130+ let (data, response) = try await session.data(for: request)
131+ let http = response as? HTTPURLResponse
132+ return .ok(HttpResponse(
133+ status: UInt16(clamping: http?.statusCode ?? 0),
134+ headers: headers(of: http),
135+ body: [UInt8](data)
136+ ))
137+ } catch let error as URLError where error.code == .cancelled {
138+ // The size-limit delegate cancels the task when the body exceeds the cap.
139+ // Treat it as a transport error so the core backs off and retries.
140+ return .err(.io("response body exceeded \(maxResponseBytes) bytes"))
141+ } catch let error as URLError where error.code == .timedOut {
142+ return .err(.timeout)
143+ } catch {
144+ return .err(.io(error.localizedDescription))
145+ }
146+ }
147+
148+ /// HTTP header names are case-insensitive; the core matches them lowercased.
149+ private static func headers(of response: HTTPURLResponse?) -> [HttpHeader] {
150+ guard let response else { return [] }
151+ return response.allHeaderFields.compactMap { (name, value) -> HttpHeader? in
152+ guard let name = name as? String, let value = value as? String else { return nil }
153+ return HttpHeader(name: name.lowercased(), value: value)
154+ }
155+ }
156+}
157+
158+// MARK: - Timer
159+
160+@MainActor
161+enum Time {
162+ /// Live timers, so `.clear` can actually cancel one. Resolving a crux effect twice
163+ /// (once from `.clear`, once from the work item that was never cancelled) traps in
164+ /// the core, so a dangling work item is a latent crash, not just wasted work.
165+ private static var timers: [TimerId: DispatchWorkItem] = [:]
166+
167+ static func handle(_ req: TimeRequest, resolve: @MainActor @escaping (TimeResponse) -> Void) {
168+ switch req {
169+ case .now:
170+ let now = Date().timeIntervalSince1970
171+ let seconds = UInt64(now)
172+ let nanos = UInt32((now - Double(seconds)) * 1_000_000_000)
173+ resolve(.now(instant: Instant(seconds: seconds, nanos: nanos)))
174+ case .notifyAfter(let id, let duration):
175+ let seconds = Double(duration.nanos) / 1_000_000_000
176+ schedule(id, after: seconds) { resolve(.durationElapsed(id: id)) }
177+ case .notifyAt(let id, let instant):
178+ let target = Date(timeIntervalSince1970: Double(instant.seconds)
179+ + Double(instant.nanos) / 1_000_000_000)
180+ schedule(id, after: max(0, target.timeIntervalSinceNow)) {
181+ resolve(.instantArrived(id: id))
182+ }
183+ case .clear(let id):
184+ timers.removeValue(forKey: id)?.cancel()
185+ resolve(.cleared(id: id))
186+ }
187+ }
188+
189+ private static func schedule(
190+ _ id: TimerId,
191+ after seconds: TimeInterval,
192+ fire: @MainActor @escaping () -> Void
193+ ) {
194+ timers.removeValue(forKey: id)?.cancel() // a reused id supersedes the old timer
195+ let work = DispatchWorkItem {
196+ MainActor.assumeIsolated {
197+ timers.removeValue(forKey: id)
198+ fire()
199+ }
200+ }
201+ timers[id] = work
202+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
203+ }
204+}
205+
206+// MARK: - Key-value store (one file per key under Application Support)
207+
208+final class KvStore {
209+ private let dir: URL
210+ /// Serial: the core issues overlapping reads and writes to the same key, and the
211+ /// file for a key is the only copy of that state. Off the main actor, because
212+ /// `messages:<peer>` grows without bound and a write of it must not stutter the UI.
213+ private let queue = DispatchQueue(label: "lol.skrepka.kv")
214+
215+ init() {
216+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
217+ var url = base.appendingPathComponent("skrepka/kv", isDirectory: true)
218+ try? FileManager.default.createDirectory(
219+ at: url,
220+ withIntermediateDirectories: true,
221+ attributes: [.protectionKey: FileProtectionType.completeUnlessOpen]
222+ )
223+ // Plaintext message history: it must not ride along in an iCloud/iTunes backup,
224+ // which is not covered by the device passcode the way the Keychain item is.
225+ var values = URLResourceValues()
226+ values.isExcludedFromBackup = true
227+ try? url.setResourceValues(values)
228+ dir = url
229+
230+ // Ordered ahead of every operation: `handle` runs on the same serial queue, so
231+ // the rename is done before the first read can look for a file under the new name.
232+ queue.async { [self] in migrateLegacyNames() }
233+ }
234+
235+ /// Rename the files an older build wrote, which substituted `_` for `:`.
236+ ///
237+ /// `messages:<peer>` lived in `messages_<peer>`; `fileURL(forKey:)` now percent-encodes,
238+ /// so it looks for `messages%3A<peer>` and finds nothing. Without this, an upgrade opens
239+ /// every conversation empty — and the first new message writes a fresh file, orphaning
240+ /// the old one (plaintext history) on disk forever. The other five keys are alphanumeric,
241+ /// so their names are unchanged and they need no migration.
242+ private func migrateLegacyNames() {
243+ let fm = FileManager.default
244+ guard let names = try? fm.contentsOfDirectory(atPath: dir.path) else { return }
245+ for name in names where name.hasPrefix("messages_") {
246+ let peer = String(name.dropFirst("messages_".count))
247+ let destination = fileURL(forKey: "messages:\(peer)")
248+ guard !fm.fileExists(atPath: destination.path) else { continue }
249+ try? fm.moveItem(at: dir.appendingPathComponent(name), to: destination)
250+ }
251+ }
252+
253+ /// Runs the operation on the serial queue and resolves from there. The core does not
254+ /// care which thread resolves an effect — `Core` hops the result back to the main
255+ /// actor before it touches the state machine.
256+ func handle(_ op: KeyValueOperation, completion: @escaping (KeyValueResult) -> Void) {
257+ queue.async { [self] in completion(perform(op)) }
258+ }
259+
260+ private func perform(_ op: KeyValueOperation) -> KeyValueResult {
261+ switch op {
262+ case .get(let key):
263+ switch read(key) {
264+ case .success(let value): return .ok(response: .get(value: value))
265+ case .failure(let error): return .err(error: .io(message: error.localizedDescription))
266+ }
267+
268+ case .set(let key, let value):
269+ do {
270+ // Atomic: a crash mid-write must not leave a truncated messages:<peer>.
271+ try Data(value).write(
272+ to: fileURL(forKey: key),
273+ options: [.atomic, .completeFileProtectionUnlessOpen]
274+ )
275+ // The core discards `previous`, so reading the old value back would
276+ // double the cost of every write to buy nothing.
277+ return .ok(response: .set(previous: .none))
278+ } catch {
279+ return .err(error: .io(message: error.localizedDescription))
280+ }
281+
282+ case .delete(let key):
283+ switch read(key) {
284+ case .failure(let error):
285+ return .err(error: .io(message: error.localizedDescription))
286+ case .success(let previous):
287+ let url = fileURL(forKey: key)
288+ do {
289+ if FileManager.default.fileExists(atPath: url.path) {
290+ try FileManager.default.removeItem(at: url)
291+ }
292+ return .ok(response: .delete(previous: previous))
293+ } catch {
294+ return .err(error: .io(message: error.localizedDescription))
295+ }
296+ }
297+
298+ case .exists(let key):
299+ let present = FileManager.default.fileExists(atPath: fileURL(forKey: key).path)
300+ return .ok(response: .exists(isPresent: present))
301+
302+ case .listKeys:
303+ // Never used by the core. Reporting success with no keys would read as
304+ // "the store is empty" — say so honestly instead.
305+ return .err(error: .io(message: "listKeys is not implemented"))
306+ }
307+ }
308+
309+ /// A missing file is an empty key; anything else is a real failure. Collapsing the
310+ /// two would let a transient read error look like "no contacts yet", and the next
311+ /// save would write that emptiness back over the real store.
312+ private func read(_ key: String) -> Result<Value, Error> {
313+ let url = fileURL(forKey: key)
314+ guard FileManager.default.fileExists(atPath: url.path) else { return .success(.none) }
315+ do {
316+ return .success(.bytes([UInt8](try Data(contentsOf: url))))
317+ } catch {
318+ return .failure(error)
319+ }
320+ }
321+
322+ /// Percent-encoding, so the key → filename mapping is injective. Substituting `_`
323+ /// for both `:` and `/` would collide `messages:ab` with `messages/ab`, and two
324+ /// peers' histories would land in the same file.
325+ private func fileURL(forKey key: String) -> URL {
326+ let encoded = key.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? key
327+ return dir.appendingPathComponent(encoded)
328+ }
329+}
330+
331+// MARK: - Keychain identity (64-byte libsodium-form Ed25519 secret key)
332+
333+enum Keychain {
334+ private static let service = "lol.skrepka.identity"
335+ private static let account = "identity"
336+
337+ /// `Result`'s failure type has to be an `Error`, and `OSStatus` is a bare `Int32`.
338+ struct Status: Error { let code: OSStatus }
339+
340+ enum IdentityError: Error {
341+ /// The item may well exist — we just could not read it (e.g. the device has not
342+ /// been unlocked since boot). Never generate a replacement on this path.
343+ case unreadable(OSStatus)
344+ case corrupt(Int)
345+ case notStored(OSStatus)
346+
347+ var message: String {
348+ switch self {
349+ case .unreadable(let status):
350+ return "Could not read the identity key from the Keychain (\(status)). "
351+ + "Unlock the device and try again."
352+ case .corrupt(let count):
353+ return "The stored identity key is \(count) bytes, not 64. "
354+ + "Reinstalling the app will create a new identity — the old one is unrecoverable."
355+ case .notStored(let status):
356+ return "Could not save the new identity key to the Keychain (\(status))."
357+ }
358+ }
359+ }
360+
361+ /// Load the stored 64-byte key, or — only when the Keychain positively reports that
362+ /// no item exists — generate and persist a fresh one.
363+ static func loadOrCreateIdentity() -> Result<Data, IdentityError> {
364+ switch load() {
365+ case .failure(let status):
366+ return .failure(.unreadable(status.code))
367+
368+ case .success(.some(let existing)):
369+ guard existing.count == 64 else { return .failure(.corrupt(existing.count)) }
370+ return .success(existing)
371+
372+ case .success(.none):
373+ // Generate a valid Ed25519 keypair; sk64 = seed(32) || pub(32).
374+ let priv = Curve25519.Signing.PrivateKey()
375+ var key = Data()
376+ key.append(priv.rawRepresentation) // 32-byte seed
377+ key.append(priv.publicKey.rawRepresentation) // 32-byte public key
378+ if let status = store(key) { return .failure(.notStored(status)) }
379+ return .success(key)
380+ }
381+ }
382+
383+ /// `errSecItemNotFound` is the only status that means "no identity yet". Every other
384+ /// failure leaves the question open, and the caller must not answer it by minting a
385+ /// new keypair — that would overwrite an identity we merely failed to read.
386+ private static func load() -> Result<Data?, Status> {
387+ let query: [String: Any] = [
388+ kSecClass as String: kSecClassGenericPassword,
389+ kSecAttrService as String: service,
390+ kSecAttrAccount as String: account,
391+ kSecReturnData as String: true,
392+ kSecMatchLimit as String: kSecMatchLimitOne,
393+ ]
394+ var item: CFTypeRef?
395+ let status = SecItemCopyMatching(query as CFDictionary, &item)
396+ switch status {
397+ case errSecSuccess: return .success(item as? Data)
398+ case errSecItemNotFound: return .success(nil)
399+ default: return .failure(Status(code: status))
400+ }
401+ }
402+
403+ /// Update in place, adding only if nothing is there. Delete-then-add would open a
404+ /// window where a crash (or a kill) leaves no key at all, and the next launch would
405+ /// silently mint a *new* identity — losing the old one, and with it every contact's
406+ /// idea of who we are.
407+ ///
408+ /// Returns `nil` on success, or the failing `OSStatus`.
409+ private static func store(_ data: Data) -> OSStatus? {
410+ let query: [String: Any] = [
411+ kSecClass as String: kSecClassGenericPassword,
412+ kSecAttrService as String: service,
413+ kSecAttrAccount as String: account,
414+ ]
415+ let attributes: [String: Any] = [
416+ kSecValueData as String: data,
417+ // ThisDeviceOnly: the identity key must never ride along in a device backup.
418+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
419+ ]
420+
421+ let updated = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
422+ if updated == errSecSuccess { return nil }
423+ guard updated == errSecItemNotFound else { return updated }
424+
425+ let added = SecItemAdd(query.merging(attributes) { current, _ in current } as CFDictionary, nil)
426+ return added == errSecSuccess ? nil : added
427+ }
428+}
apple/SkrepkaApp/Forms.swiftnew file+198
@@ -0,0 +1,198 @@
1+import ImageIO
2+import Skrepka
3+import SwiftUI
4+import UIKit
5+
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? {
17+ guard !base64.isEmpty, let data = Data(base64Encoded: base64) else { return nil }
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)
27+}
28+
29+struct AddContactView: View {
30+ @ObservedObject var core: Core
31+ @State private var input = ""
32+ @State private var nickname = ""
33+ @State private var scanning = false
34+
35+ var body: some View {
36+ NavigationStack {
37+ Form {
38+ Section("Public key") {
39+ TextField("@p syllables or hex", text: $input, axis: .vertical)
40+ .autocorrectionDisabled()
41+ .textInputAutocapitalization(.never)
42+ Button { scanning = true } label: {
43+ Label("Scan QR code", systemImage: "qrcode.viewfinder")
44+ }
45+ }
46+ Section("Nickname (optional)") {
47+ TextField("e.g. Alice", text: $nickname)
48+ }
49+ if !core.view.error.isEmpty {
50+ Text(core.view.error).foregroundColor(.red)
51+ }
52+ }
53+ .navigationTitle("Add contact")
54+ .toolbar {
55+ ToolbarItem(placement: .cancellationAction) {
56+ Button("Cancel") { core.update(.showConversations) }
57+ }
58+ ToolbarItem(placement: .confirmationAction) {
59+ Button("Add") {
60+ core.update(.addContact(input: input, nickname: nickname))
61+ }.disabled(input.isEmpty)
62+ }
63+ }
64+ .sheet(isPresented: $scanning) {
65+ QRScannerView { code in
66+ input = scannedKeyPayload(code)
67+ scanning = false
68+ }
69+ }
70+ }
71+ }
72+}
73+
74+struct SettingsView: View {
75+ @ObservedObject var core: Core
76+ @State private var serverUrl = ""
77+ @State private var copied = false
78+
79+ var body: some View {
80+ NavigationStack {
81+ Form {
82+ Section("Your identity") {
83+ HStack {
84+ Avatar(base64: core.view.profile.photo, name: core.view.profile.displayName)
85+ VStack(alignment: .leading) {
86+ Text(core.view.profile.displayName.isEmpty
87+ ? "No name" : core.view.profile.displayName).font(.headline)
88+ Text(core.view.connStatus.capitalized)
89+ .font(.caption).foregroundColor(.secondary)
90+ }
91+ }
92+ if let qr = qrImage(core.view.myPubkeyHex) {
93+ Image(uiImage: qr).interpolation(.none).resizable().scaledToFit()
94+ .frame(maxWidth: 200).frame(maxWidth: .infinity)
95+ }
96+ Text(core.view.myPubkeyOb).font(.system(.footnote, design: .monospaced))
97+ .textSelection(.enabled)
98+ Button {
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+ )
105+ copied = true
106+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
107+ } label: {
108+ Label(copied ? "Copied!" : "Copy key", systemImage: "doc.on.doc")
109+ }
110+ Button { core.update(.showEditProfile) } label: {
111+ Label("Edit profile", systemImage: "pencil")
112+ }
113+ }
114+ Section("Server") {
115+ TextField("https://relay.example.com", text: $serverUrl)
116+ .autocorrectionDisabled().textInputAutocapitalization(.never)
117+ Button("Save & reconnect") {
118+ core.update(.setServerUrl(serverUrl))
119+ }.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+ }
125+ }
126+ }
127+ .navigationTitle("Settings")
128+ .toolbar {
129+ ToolbarItem(placement: .cancellationAction) {
130+ Button("Done") { core.update(.showConversations) }
131+ }
132+ }
133+ .onAppear { serverUrl = core.view.serverUrl }
134+ }
135+ }
136+}
137+
138+struct EditProfileView: View {
139+ @ObservedObject var core: Core
140+ @State private var name = ""
141+ @State private var bio = ""
142+ @State private var photo: String?
143+ @State private var picking = false
144+
145+ var body: some View {
146+ NavigationStack {
147+ Form {
148+ Section {
149+ HStack {
150+ Spacer()
151+ Button { picking = true } label: {
152+ Avatar(base64: photo ?? "", name: name, size: 88)
153+ }
154+ Spacer()
155+ }
156+ Button("Choose photo") { picking = true }
157+ .frame(maxWidth: .infinity)
158+ }
159+ Section("Name") { TextField("Display name", text: $name) }
160+ Section("Bio") { TextField("About you", text: $bio, axis: .vertical) }
161+ // SaveProfile sets model.error on validation failures ("display name
162+ // too long", "bio too long", "photo too large") but leaves the page
163+ // open. Without displaying it the user taps Save, nothing happens, and
164+ // they have no idea why.
165+ if !core.view.error.isEmpty {
166+ Section {
167+ Text(core.view.error).foregroundColor(.red)
168+ }
169+ }
170+ }
171+ .navigationTitle("Edit profile")
172+ .toolbar {
173+ ToolbarItem(placement: .cancellationAction) {
174+ Button("Cancel") { core.update(.showConversations) }
175+ }
176+ ToolbarItem(placement: .confirmationAction) {
177+ Button("Save") {
178+ core.update(.saveProfile(displayName: name, bio: bio, photo: photo))
179+ }
180+ }
181+ }
182+ .sheet(isPresented: $picking) {
183+ PhotoPicker(
184+ onPick: { base64 in
185+ photo = base64
186+ picking = false
187+ },
188+ onCancel: { picking = false }
189+ )
190+ }
191+ .onAppear {
192+ name = core.view.profile.displayName
193+ bio = core.view.profile.bio
194+ photo = core.view.profile.photo.isEmpty ? nil : core.view.profile.photo
195+ }
196+ }
197+ }
198+}
apple/SkrepkaApp/Info-iOS.plistnew file+49
@@ -0,0 +1,49 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>CFBundleDevelopmentRegion</key>
6+ <string>$(DEVELOPMENT_LANGUAGE)</string>
7+ <key>CFBundleDisplayName</key>
8+ <string>Skrepka</string>
9+ <key>CFBundleExecutable</key>
10+ <string>$(EXECUTABLE_NAME)</string>
11+ <key>CFBundleIdentifier</key>
12+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13+ <key>CFBundleInfoDictionaryVersion</key>
14+ <string>6.0</string>
15+ <key>CFBundleName</key>
16+ <string>$(PRODUCT_NAME)</string>
17+ <key>CFBundlePackageType</key>
18+ <string>APPL</string>
19+ <key>CFBundleShortVersionString</key>
20+ <string>1.0</string>
21+ <key>CFBundleVersion</key>
22+ <string>1</string>
23+ <key>NSAppTransportSecurity</key>
24+ <dict>
25+ <key>NSAllowsLocalNetworking</key>
26+ <true/>
27+ <key>NSExceptionDomains</key>
28+ <dict>
29+ <key>localhost</key>
30+ <dict>
31+ <key>NSExceptionAllowsInsecureHTTPLoads</key>
32+ <true/>
33+ <key>NSIncludesSubdomains</key>
34+ <true/>
35+ </dict>
36+ </dict>
37+ </dict>
38+ <key>NSCameraUsageDescription</key>
39+ <string>Scan a contact's QR code to add them.</string>
40+ <key>NSPhotoLibraryUsageDescription</key>
41+ <string>Choose a photo for your profile.</string>
42+ <key>UILaunchScreen</key>
43+ <dict/>
44+ <key>UISupportedInterfaceOrientations</key>
45+ <array>
46+ <string>UIInterfaceOrientationPortrait</string>
47+ </array>
48+</dict>
49+</plist>
apple/SkrepkaApp/PhotoPicker.swiftnew file+90
@@ -0,0 +1,90 @@
1+import ImageIO
2+import PhotosUI
3+import SwiftUI
4+import UIKit
5+import UniformTypeIdentifiers
6+
7+/// Presents the system photo picker; returns a 256px JPEG as base64.
8+struct PhotoPicker: UIViewControllerRepresentable {
9+ let onPick: (String) -> Void
10+ var onCancel: () -> Void = {}
11+
12+ func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick, onCancel: onCancel) }
13+
14+ func makeUIViewController(context: Context) -> PHPickerViewController {
15+ var config = PHPickerConfiguration()
16+ config.filter = .images
17+ config.selectionLimit = 1
18+ let picker = PHPickerViewController(configuration: config)
19+ picker.delegate = context.coordinator
20+ return picker
21+ }
22+
23+ func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
24+
25+ final class Coordinator: NSObject, PHPickerViewControllerDelegate {
26+ let onPick: (String) -> Void
27+ let onCancel: () -> Void
28+
29+ init(onPick: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
30+ self.onPick = onPick
31+ self.onCancel = onCancel
32+ }
33+
34+ 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.
38+ guard let provider = results.first?.itemProvider,
39+ provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
40+ DispatchQueue.main.async { self.onCancel() }
41+ return
42+ }
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) }
46+ DispatchQueue.main.async {
47+ if let base64 { self.onPick(base64) } else { self.onCancel() }
48+ }
49+ }
50+ }
51+
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+ let image = UIImage(cgImage: thumb)
66+ // Reduce quality until the base64 fits within the protocol cap (MAX_PHOTO_LEN,
67+ // 64 KiB). A high-detail 256px frame can exceed the cap even at q0.7; without
68+ // this loop `SaveProfile` rejects the whole profile with no recourse for the user.
69+ let maxBase64Len = 64 * 1024
70+ var quality: CGFloat = 0.7
71+ while quality > 0.1 {
72+ if let jpegData = image.jpegData(compressionQuality: quality) {
73+ let base64 = jpegData.base64EncodedString()
74+ if base64.count <= maxBase64Len {
75+ return base64
76+ }
77+ }
78+ quality -= 0.1
79+ }
80+ // Last resort: the smallest quality that still produces something.
81+ // If even this exceeds the cap, return nil so onCancel fires rather
82+ // than sending an oversized payload the core will reject.
83+ let lastResort = image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
84+ if let result = lastResort, result.count <= maxBase64Len {
85+ return result
86+ }
87+ return nil
88+ }
89+ }
90+}
apple/SkrepkaApp/QRView.swiftnew file+149
@@ -0,0 +1,149 @@
1+import AVFoundation
2+import CoreImage.CIFilterBuiltins
3+import SwiftUI
4+import UIKit
5+
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+
10+/// Generate a QR code image from a string.
11+func qrImage(_ string: String) -> UIImage? {
12+ let context = ciContext
13+ let filter = CIFilter.qrCodeGenerator()
14+ filter.message = Data(string.utf8)
15+ guard let output = filter.outputImage else { return nil }
16+ let scaled = output.transformed(by: CGAffineTransform(scaleX: 8, y: 8))
17+ guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
18+ return UIImage(cgImage: cg)
19+}
20+
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+// NOTE: This URL scheme stripping is arguably business logic that belongs in
25+// the core (see AGENTS.md). It is kept here for now because moving it requires
26+// an FFI change. The core's `try_parse_pubkey` validates the result regardless.
27+func scannedKeyPayload(_ raw: String) -> String {
28+ var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
29+ let schemes = ["skrepka://", "skrepka:", "https://", "http://"]
30+ if let scheme = schemes.first(where: { value.lowercased().hasPrefix($0) }) {
31+ value = String(value.dropFirst(scheme.count))
32+ // What remains of a link is host/path#key — the key is the last component.
33+ if let key = value.split(whereSeparator: { "/#?".contains($0) }).last {
34+ value = String(key)
35+ }
36+ }
37+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
38+}
39+
40+/// A camera-backed QR scanner. Calls `onScan` with the decoded payload once.
41+struct QRScannerView: UIViewControllerRepresentable {
42+ let onScan: (String) -> Void
43+
44+ func makeCoordinator() -> Coordinator { Coordinator(onScan: onScan) }
45+
46+ func makeUIViewController(context: Context) -> ScannerVC {
47+ let vc = ScannerVC()
48+ vc.coordinator = context.coordinator
49+ return vc
50+ }
51+
52+ func updateUIViewController(_ uiViewController: ScannerVC, context: Context) {}
53+
54+ final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
55+ let onScan: (String) -> Void
56+ private var done = false
57+ init(onScan: @escaping (String) -> Void) { self.onScan = onScan }
58+
59+ func metadataOutput(
60+ _ output: AVCaptureMetadataOutput,
61+ didOutput metadataObjects: [AVMetadataObject],
62+ from connection: AVCaptureConnection
63+ ) {
64+ guard !done,
65+ let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
66+ let value = obj.stringValue else { return }
67+ done = true
68+ DispatchQueue.main.async { self.onScan(value) }
69+ }
70+ }
71+}
72+
73+final class ScannerVC: UIViewController {
74+ weak var coordinator: QRScannerView.Coordinator?
75+ private let session = AVCaptureSession()
76+ private var previewLayer: AVCaptureVideoPreviewLayer?
77+
78+ override func viewDidLoad() {
79+ super.viewDidLoad()
80+ view.backgroundColor = .black
81+
82+ let status = AVCaptureDevice.authorizationStatus(for: .video)
83+ switch status {
84+ case .authorized:
85+ startSession()
86+ case .notDetermined:
87+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
88+ DispatchQueue.main.async {
89+ if granted { self?.startSession() }
90+ else { self?.showPermissionDenied() }
91+ }
92+ }
93+ default:
94+ showPermissionDenied()
95+ }
96+ }
97+
98+ private func startSession() {
99+ guard let device = AVCaptureDevice.default(for: .video),
100+ let input = try? AVCaptureDeviceInput(device: device),
101+ session.canAddInput(input) else { return }
102+ session.addInput(input)
103+
104+ let output = AVCaptureMetadataOutput()
105+ guard session.canAddOutput(output) else { return }
106+ session.addOutput(output)
107+ output.setMetadataObjectsDelegate(coordinator, queue: .main)
108+ output.metadataObjectTypes = [.qr]
109+
110+ let preview = AVCaptureVideoPreviewLayer(session: session)
111+ preview.frame = view.layer.bounds
112+ preview.videoGravity = .resizeAspectFill
113+ view.layer.addSublayer(preview)
114+ previewLayer = preview
115+ }
116+
117+ private func showPermissionDenied() {
118+ let label = UILabel()
119+ label.text = "Camera permission required"
120+ label.textAlignment = .center
121+ label.textColor = .secondaryLabel
122+ view.addSubview(label)
123+ label.translatesAutoresizingMaskIntoConstraints = false
124+ NSLayoutConstraint.activate([
125+ label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
126+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
127+ ])
128+ }
129+
130+ override func viewDidLayoutSubviews() {
131+ super.viewDidLayoutSubviews()
132+ previewLayer?.frame = view.bounds
133+ }
134+
135+ override func viewWillAppear(_ animated: Bool) {
136+ super.viewWillAppear(animated)
137+ if !session.isRunning && !session.inputs.isEmpty {
138+ DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
139+ }
140+ }
141+
142+ override func viewWillDisappear(_ animated: Bool) {
143+ super.viewWillDisappear(animated)
144+ // Both start and stop block until the camera pipeline settles — never on main.
145+ if session.isRunning {
146+ DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
147+ }
148+ }
149+}
apple/SkrepkaApp/SkrepkaApp.swiftnew file+166
@@ -0,0 +1,166 @@
1+import BackgroundTasks
2+import SwiftUI
3+import UIKit
4+
5+@main
6+struct SkrepkaApp: App {
7+ /// Must stay in step with `BGTaskSchedulerPermittedIdentifiers` in `project.yml`.
8+ /// An identifier the Info.plist does not list is refused by `submit` — quietly, and
9+ /// forever: the task simply never runs, and nothing at runtime says why.
10+ static let refreshTaskId = "lol.skrepka.SkrepkaApp.fetch"
11+
12+ @StateObject private var core = Core.shared
13+ @Environment(\.scenePhase) private var scenePhase
14+
15+ init() {
16+ // Launch handlers must all be registered before the app finishes launching, so this
17+ // cannot wait for a view to appear. The handler is called on a background queue;
18+ // `BackgroundRefresh` hops to the main actor, where the core lives.
19+ BGTaskScheduler.shared.register(
20+ forTaskWithIdentifier: Self.refreshTaskId,
21+ using: nil
22+ ) { task in
23+ guard let task = task as? BGAppRefreshTask else {
24+ task.setTaskCompleted(success: false)
25+ return
26+ }
27+ BackgroundRefresh.handle(task)
28+ }
29+ }
30+
31+ var body: some Scene {
32+ WindowGroup {
33+ RootView(core: core)
34+ .onChange(of: scenePhase) { _, phase in
35+ switch phase {
36+ case .active:
37+ // Backgrounding kills the long-poll socket and the app stops running,
38+ // so on return the core would otherwise sit out its whole backoff
39+ // (up to 30s) before noticing. Reconnect immediately instead.
40+ guard core.view.hasIdentity else { return }
41+ core.update(.connect)
42+ case .background:
43+ // Ask for a refresh pass on the way out — that is the only window in
44+ // which a pending request is any use, and the system replaces the
45+ // previous request for this identifier rather than queueing another.
46+ BackgroundRefresh.schedule()
47+ default:
48+ break
49+ }
50+ }
51+ }
52+ }
53+}
54+
55+/// Opportunistic catch-up while the app is backgrounded.
56+///
57+/// This does **not** keep the poll loop alive — iOS grants no such thing to a non-VoIP app.
58+/// `BGAppRefreshTask` wakes us for a few tens of seconds at times the system chooses from the
59+/// user's launch habits (`earliestBeginDate` is a floor, not a schedule: a pass may be hours
60+/// late, or never come at all if the app was force-quit), and suspends us again as soon as we
61+/// report completion. So this drains whatever the relay is already holding, on the system's
62+/// terms. Prompt delivery would need a push, and the protocol has no APNs surface at all.
63+@MainActor
64+enum BackgroundRefresh {
65+ /// The system coalesces and defers as it sees fit; asking for less does not make it come
66+ /// sooner, it only tells the scheduler we have nothing useful to do before then.
67+ private static let earliestBegin: TimeInterval = 30 * 60
68+
69+ /// The grant is around 30s, and an app that habitually burns all of it — or worse, gets
70+ /// expired — is scheduled less often. Leave headroom.
71+ private static let budget: TimeInterval = 25
72+
73+ /// How long to hold the poll open once we are online. The relay answers a poll at once
74+ /// when it is holding mail and otherwise long-polls ~25s, so a few seconds of silence
75+ /// already *is* the answer "nothing waiting" — there is nothing to be gained by waiting
76+ /// out the long poll, and the budget is better left unspent.
77+ private static let idleGrace: TimeInterval = 5
78+
79+ static func schedule() {
80+ let request = BGAppRefreshTaskRequest(identifier: SkrepkaApp.refreshTaskId)
81+ request.earliestBeginDate = Date(timeIntervalSinceNow: earliestBegin)
82+ // Throws on `.notPermitted` (the identifier is missing from the Info.plist — a
83+ // packaging bug, see `SkrepkaApp.refreshTaskId`) and on `.tooManyPendingTaskRequests`.
84+ // Neither is actionable from here, and neither is worth failing a scene transition.
85+ try? BGTaskScheduler.shared.submit(request)
86+ }
87+
88+ nonisolated static func handle(_ task: BGAppRefreshTask) {
89+ let work = Task { @MainActor in await run(task) }
90+ // Expiry only *cancels*; `run` owns the single `setTaskCompleted` call.
91+ // The handler is assigned immediately after Task creation — the window
92+ // where the task is alive but unhandled is one assignment, and the system
93+ // does not expire a task in its first few milliseconds.
94+ task.expirationHandler = { work.cancel() }
95+ }
96+
97+ private static func run(_ task: BGAppRefreshTask) async {
98+ // Before anything that can fail or be cut short: we still want the next pass.
99+ schedule()
100+
101+ // Every kv file is written `completeUnlessOpen`, so while the device is
102+ // locked an *existing* one cannot be opened and every read fails. The
103+ // core now distinguishes a failed read from an absent key (`parse_kv`
104+ // returns `Err` vs `Ok(None)`), so it surfaces a storage error instead
105+ // of silently loading an empty model. But the guard is still the right
106+ // call: a locked device means the core would come up with errors on
107+ // every key, no contacts, and no history — and even if it doesn't
108+ // overwrite, it would poll into an empty conversation list and ack
109+ // messages the user can't see. Locked means we must not run at all.
110+ guard UIApplication.shared.isProtectedDataAvailable else {
111+ task.setTaskCompleted(success: false)
112+ return
113+ }
114+
115+ let deadline = Date(timeIntervalSinceNow: budget)
116+ let core = Core.shared
117+
118+ // Reading `Core.shared` is what boots the core when the system launched us straight
119+ // into the background. Startup is asynchronous — the identity, then five kv loads —
120+ // and the core fires its own `Connect` once the settings land. It must be left to do
121+ // that: nudging it beforehand would authenticate against the *default* relay, because
122+ // the configured URL has not been read off disk yet.
123+ guard await settle(by: deadline, until: { core.view.hasIdentity }) else {
124+ task.setTaskCompleted(success: false)
125+ return
126+ }
127+
128+ // Still offline once startup has had its chance: either the core is idle, or it was
129+ // suspended part-way through a backoff whose timer will not come due for another 30s.
130+ // A `Connect` collapses that wait. (It is a no-op when the core already believes it
131+ // is online — there, a stale poll is the watchdog's problem, not ours.)
132+ let startupDeadline = min(deadline, Date(timeIntervalSinceNow: 3))
133+ let connecting = await settle(by: startupDeadline, until: {
134+ core.view.connStatus != "offline"
135+ })
136+ if !connecting {
137+ core.update(.connect)
138+ }
139+
140+ // Online is the token in hand and the poll issued. Anything the relay was holding
141+ // comes back on that first page.
142+ guard await settle(by: deadline, until: { core.view.connStatus == "online" }) else {
143+ task.setTaskCompleted(success: false)
144+ return
145+ }
146+ try? await Task.sleep(for: .seconds(idleGrace))
147+
148+ task.setTaskCompleted(success: !Task.isCancelled)
149+ }
150+
151+ /// Waits for a condition on the view, or for the deadline (or expiry) to overtake it.
152+ ///
153+ /// The core has nothing to await — it publishes a `ViewModel` and that is the whole of
154+ /// its output — so the shell watches that. A 500ms tick across the handful of transitions
155+ /// this cares about is cheaper than threading a Combine subscription through an actor hop.
156+ /// The previous 200ms interval was unnecessarily aggressive for a background task with a
157+ /// 25s budget that only cares about a few state transitions.
158+ private static func settle(by deadline: Date, until condition: () -> Bool) async -> Bool {
159+ while !condition() {
160+ guard !Task.isCancelled, Date() < deadline else { return false }
161+ // Cancellation makes this throw at once; the guard above is what breaks the loop.
162+ try? await Task.sleep(for: .milliseconds(500))
163+ }
164+ return true
165+ }
166+}
apple/SkrepkaApp/Views.swiftnew file+390
@@ -0,0 +1,390 @@
1+import Skrepka
2+import SwiftUI
3+import UIKit
4+
5+let accent = Color(red: 98 / 255, green: 54 / 255, blue: 1.0)
6+
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+
21+func 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 {
27+ let date = Date(timeIntervalSince1970: Double(ms) / 1000)
28+ return Calendar.current.isDateInToday(date)
29+ ? timeFormatter.string(from: date)
30+ : dateFormatter.string(from: date)
31+}
32+
33+struct RootView: View {
34+ @ObservedObject var core: Core
35+
36+ var body: some View {
37+ Group {
38+ if !core.view.hasIdentity {
39+ identity
40+ } else {
41+ switch core.view.page {
42+ case "chat": ChatView(core: core)
43+ case "add_contact": AddContactView(core: core)
44+ case "settings": SettingsView(core: core)
45+ case "edit_profile": EditProfileView(core: core)
46+ default: ConversationsView(core: core)
47+ }
48+ }
49+ }
50+ }
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+ }
72+}
73+
74+struct ConnDot: View {
75+ let status: String
76+ var color: Color {
77+ switch status {
78+ case "online": return .green
79+ case "connecting": return .orange
80+ default: return .gray
81+ }
82+ }
83+ var body: some View {
84+ HStack(spacing: 5) {
85+ Circle().fill(color).frame(width: 8, height: 8)
86+ Text(status.capitalized).font(.caption).foregroundColor(.secondary)
87+ }
88+ .fixedSize()
89+ }
90+}
91+
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+
101+struct Avatar: View {
102+ let base64: String
103+ let name: String
104+ var size: CGFloat = 44
105+ @State private var image: UIImage?
106+
107+ var body: some View {
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+ }
119+ }
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
138+ }
139+}
140+
141+func initials(_ name: String) -> String {
142+ let parts = name.split(separator: " ")
143+ let chars = parts.prefix(2).compactMap { $0.first }
144+ return chars.isEmpty ? "?" : String(chars).uppercased()
145+}
146+
147+/// Toggles `Contact::blocked` in the core. Blocked peers stay in the contact list —
148+/// listed apart — so the block is always reversible from the UI.
149+struct BlockToggle: View {
150+ @ObservedObject var core: Core
151+ let peer: String
152+ let blocked: Bool
153+
154+ var body: some View {
155+ Button(role: blocked ? nil : .destructive) {
156+ core.update(.setBlocked(peer: peer, blocked: !blocked))
157+ } label: {
158+ Label(blocked ? "Unblock" : "Block",
159+ systemImage: blocked ? "hand.raised.slash" : "hand.raised")
160+ }
161+ }
162+}
163+
164+/// Permanently removes a contact, their conversation history, and any queued
165+/// outbox items for them. Unlike blocking (which silences but keeps the entry),
166+/// deletion is irreversible — so a confirmation prompt gates the action.
167+struct DeleteContactButton: View {
168+ @ObservedObject var core: Core
169+ let peer: String
170+
171+ @State private var confirming = false
172+
173+ var body: some View {
174+ Button(role: .destructive) {
175+ confirming = true
176+ } label: {
177+ Label("Delete contact", systemImage: "trash")
178+ }
179+ .confirmationDialog(
180+ "Delete this contact? Their message history will be removed and cannot be recovered.",
181+ isPresented: $confirming,
182+ titleVisibility: .visible
183+ ) {
184+ Button("Delete", role: .destructive) {
185+ core.update(.deleteContact(peer: peer))
186+ }
187+ Button("Cancel", role: .cancel) {}
188+ }
189+ }
190+}
191+
192+struct ConversationsView: View {
193+ @ObservedObject var core: Core
194+
195+ var visible: [ContactVM] { core.view.contacts.filter { !$0.blocked } }
196+ var blocked: [ContactVM] { core.view.contacts.filter { $0.blocked } }
197+
198+ var body: some View {
199+ NavigationStack {
200+ Group {
201+ if core.view.contacts.isEmpty {
202+ ContentUnavailableView(
203+ "No conversations",
204+ systemImage: "bubble.left.and.bubble.right",
205+ description: Text("Tap + to add a contact by their key or QR code.")
206+ )
207+ } else {
208+ List {
209+ ForEach(visible, id: \.pubkey) { row($0) }
210+ if !blocked.isEmpty {
211+ Section("Blocked") {
212+ ForEach(blocked, id: \.pubkey) { row($0).opacity(0.5) }
213+ }
214+ }
215+ }
216+ }
217+ }
218+ .navigationTitle("Skrepka")
219+ .toolbar {
220+ ToolbarItem(placement: .topBarLeading) { ConnDot(status: core.view.connStatus) }
221+ ToolbarItem(placement: .topBarTrailing) {
222+ Button { core.update(.showAddContact) } label: { Image(systemName: "plus") }
223+ }
224+ ToolbarItem(placement: .topBarTrailing) {
225+ Button { core.update(.showSettings) } label: { Image(systemName: "gearshape") }
226+ }
227+ }
228+ }
229+ }
230+
231+ private func row(_ contact: ContactVM) -> some View {
232+ Button { core.update(.openChat(contact.pubkey)) } label: {
233+ HStack(spacing: 12) {
234+ Avatar(base64: contact.photo, name: contact.name)
235+ VStack(alignment: .leading, spacing: 2) {
236+ Text(contact.name).font(.headline).foregroundColor(.primary)
237+ Text(contact.lastMessage.isEmpty ? contact.ob : contact.lastMessage)
238+ .font(.subheadline).foregroundColor(.secondary).lineLimit(1)
239+ }
240+ Spacer()
241+ if contact.lastTs > 0 {
242+ Text(formatStamp(contact.lastTs))
243+ .font(.caption).foregroundColor(.secondary)
244+ }
245+ }
246+ }
247+ .contextMenu {
248+ BlockToggle(core: core, peer: contact.pubkey, blocked: contact.blocked)
249+ DeleteContactButton(core: core, peer: contact.pubkey)
250+ }
251+ }
252+}
253+
254+struct ChatView: View {
255+ @ObservedObject var core: Core
256+ @State private var draft = ""
257+
258+ var body: some View {
259+ VStack(spacing: 0) {
260+ messages
261+ composer
262+ }
263+ .safeAreaInset(edge: .top) { header }
264+ }
265+
266+ /// The open chat's contact row. The core only fills in `photo` for this one peer,
267+ /// and only while their chat is up — a base64 avatar is far too big to clone into
268+ /// every contact on every render — so the header is the one place it can be shown.
269+ private var peer: ContactVM? {
270+ core.view.contacts.first { $0.pubkey == core.view.activePeer }
271+ }
272+
273+ private var header: some View {
274+ HStack(spacing: 12) {
275+ Button { core.update(.back) } label: { Image(systemName: "chevron.left") }
276+ Avatar(base64: peer?.photo ?? "", name: core.view.activePeerName, size: 32)
277+ VStack(alignment: .leading, spacing: 1) {
278+ Text(core.view.activePeerName).font(.headline)
279+ Text(core.view.activePeerOb).font(.caption2).foregroundColor(.secondary).lineLimit(1)
280+ }
281+ Spacer()
282+ if core.view.activePeerBlocked {
283+ Text("Blocked").font(.caption).foregroundColor(.secondary)
284+ }
285+ Menu {
286+ BlockToggle(
287+ core: core,
288+ peer: core.view.activePeer,
289+ blocked: core.view.activePeerBlocked
290+ )
291+ DeleteContactButton(core: core, peer: core.view.activePeer)
292+ } label: {
293+ Image(systemName: "ellipsis.circle")
294+ }
295+ }
296+ .padding(.horizontal).padding(.vertical, 8)
297+ .background(.bar)
298+ }
299+
300+ private var messages: some View {
301+ ScrollViewReader { proxy in
302+ ScrollView {
303+ LazyVStack(spacing: 8) {
304+ ForEach(core.view.messages, id: \.id) { msg in
305+ MessageBubble(msg: msg).id(msg.id)
306+ }
307+ }.padding()
308+ }
309+ .onAppear {
310+ // A chat opens at its newest message, not its oldest.
311+ if let last = core.view.messages.last {
312+ withAnimation(nil) { proxy.scrollTo(last.id, anchor: .bottom) }
313+ }
314+ }
315+ .onChange(of: core.view.messages.count) {
316+ if let last = core.view.messages.last { proxy.scrollTo(last.id, anchor: .bottom) }
317+ }
318+ }
319+ }
320+
321+ private var composer: some View {
322+ VStack(spacing: 0) {
323+ // Errors from send_text ("message too long", "contact list is full",
324+ // "gave up sending …", "invalid key") are set on model.error and rendered
325+ // into view.error, but without this the chat page swallowed them — the
326+ // user tapped send, it failed, and they saw nothing.
327+ if !core.view.error.isEmpty {
328+ Text(core.view.error)
329+ .font(.caption)
330+ .foregroundColor(.red)
331+ .frame(maxWidth: .infinity, alignment: .leading)
332+ .padding(.horizontal)
333+ .padding(.top, 4)
334+ }
335+ HStack(spacing: 8) {
336+ TextField("Message", text: $draft, axis: .vertical)
337+ .textFieldStyle(.roundedBorder)
338+ .lineLimit(1...4)
339+ .disabled(core.view.activePeerBlocked)
340+ Button {
341+ let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
342+ guard !text.isEmpty else { return }
343+ core.update(.composeChanged(text))
344+ core.update(.sendText)
345+ // Only clear the local draft when the core actually accepted the
346+ // message. send_text refuses (and sets error) on a blocked peer,
347+ // an oversized body, or a full contact list — and it preserves
348+ // model.compose on those paths so the user's text is not lost.
349+ // Clearing draft unconditionally wiped it from the UI even though
350+ // the core kept it, so the failure was both invisible and
351+ // destructive: the text was gone with no explanation.
352+ if core.view.error.isEmpty {
353+ draft = ""
354+ }
355+ } label: {
356+ Image(systemName: "arrow.up.circle.fill").font(.title)
357+ }
358+ .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
359+ || core.view.activePeerBlocked)
360+ }
361+ }
362+ .padding(.horizontal).padding(.vertical, 8)
363+ .background(.bar)
364+ }
365+}
366+
367+struct MessageBubble: View {
368+ let msg: MessageVM
369+ var body: some View {
370+ HStack {
371+ if msg.outgoing { Spacer(minLength: 40) }
372+ VStack(alignment: msg.outgoing ? .trailing : .leading, spacing: 2) {
373+ Text(msg.body)
374+ .foregroundColor(msg.outgoing ? .white : .primary)
375+ HStack(spacing: 4) {
376+ Text(formatTime(msg.ts)).font(.caption2)
377+ .foregroundColor(msg.outgoing ? .white.opacity(0.7) : .secondary)
378+ if msg.outgoing {
379+ Image(systemName: msg.delivered ? "checkmark.circle.fill" : "checkmark")
380+ .font(.caption2).foregroundColor(.white.opacity(0.8))
381+ }
382+ }
383+ }
384+ .padding(.horizontal, 12).padding(.vertical, 8)
385+ .background(msg.outgoing ? accent : Color(.systemGray5))
386+ .clipShape(RoundedRectangle(cornerRadius: 16))
387+ if !msg.outgoing { Spacer(minLength: 40) }
388+ }
389+ }
390+}
apple/project.ymlnew file+71
@@ -0,0 +1,71 @@
1+name: SkrepkaApp
2+packages:
3+ Shared:
4+ path: ./generated/Shared
5+ App:
6+ path: ./generated/App/Skrepka
7+options:
8+ bundleIdPrefix: lol.skrepka
9+ deploymentTarget:
10+ iOS: "17.0"
11+attributes:
12+ BuildIndependentTargetsInParallel: true
13+
14+targets:
15+ SkrepkaApp-iOS:
16+ type: application
17+ platform: iOS
18+ deploymentTarget: "17.0"
19+ sources:
20+ - path: SkrepkaApp
21+ excludes:
22+ - "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
37+ info:
38+ path: SkrepkaApp/Info-iOS.plist
39+ properties:
40+ CFBundleDisplayName: Skrepka
41+ UILaunchScreen: {}
42+ UISupportedInterfaceOrientations:
43+ - UIInterfaceOrientationPortrait
44+ NSCameraUsageDescription: Scan a contact's QR code to add them.
45+ NSPhotoLibraryUsageDescription: Choose a photo for your profile.
46+ # Opportunistic catch-up polls while backgrounded (BGAppRefreshTask). `fetch` is the
47+ # mode BGAppRefreshTask runs under; `processing` is for long deferrable work and is
48+ # not what this is. The scheduler refuses — silently — any identifier not listed in
49+ # BGTaskSchedulerPermittedIdentifiers, so it must match `SkrepkaApp.refreshTaskId`.
50+ UIBackgroundModes:
51+ - fetch
52+ BGTaskSchedulerPermittedIdentifiers:
53+ - lol.skrepka.SkrepkaApp.fetch
54+ # Declared so App Store Connect stops asking on every upload.
55+ ITSAppUsesNonExemptEncryption: false
56+ # Relays must be reachable over TLS. The only cleartext exception is a
57+ # locally-run `just server`, for development.
58+ NSAppTransportSecurity:
59+ NSAllowsLocalNetworking: true
60+ NSExceptionDomains:
61+ localhost:
62+ NSExceptionAllowsInsecureHTTPLoads: true
63+ NSIncludesSubdomains: true
64+ dependencies:
65+ - package: Shared
66+ product: SkrepkaShared
67+ - package: App
68+ product: Skrepka
69+ scheme:
70+ management:
71+ shared: true
client/ApiClient.fsdeleted−101
@@ -1,101 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.Net.Http
5−open System.Text
6−open System.Text.Json
7−open System.Text.Json.Serialization
8−open System.Threading.Tasks
9−
10−module ApiClient =
11−
12− exception ApiError of string
13− exception ServerRejected of string
14− exception Unauthorized
15−
16− [<CLIMutable>]
17− type PollEvent = { EncryptedBlob: string }
18−
19− [<CLIMutable>]
20− type PollResponse = { Cursor: int64; Events: PollEvent array }
21−
22− /// Awaits a Task without converting TaskCanceledException to F# async
23− /// cancellation (which bypasses try...with). Re-raises it as a regular exception.
24− let private awaitTask (t: Task<'T>) : Async<'T> =
25− Async.FromContinuations(fun (ok, err, _cancel) ->
26− t.ContinueWith(fun (t: Task<'T>) ->
27− if t.IsFaulted then err t.Exception.InnerException
28− elif t.IsCanceled then err (TimeoutException("Request timed out"))
29− else ok t.Result)
30− |> ignore)
31−
32− let private jsonOpts =
33− JsonSerializerOptions(PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString)
34−
35− let private client =
36− let c = new HttpClient()
37− c.Timeout <- TimeSpan.FromSeconds(60.)
38− c
39−
40− let private pollClient =
41− let c = new HttpClient()
42− c.Timeout <- TimeSpan.FromSeconds(30.)
43− c
44−
45− let private sendRequest (httpClient: HttpClient) (url: string) (body: string) (token: string option) =
46− async {
47− use request = new HttpRequestMessage(HttpMethod.Post, url)
48− request.Content <- new StringContent(body, Encoding.UTF8, "application/json")
49− token |> Option.iter (fun t -> request.Headers.Add("Authorization", $"Bearer {t}"))
50− use! response = httpClient.SendAsync(request) |> awaitTask
51− let! text = response.Content.ReadAsStringAsync() |> awaitTask
52− if response.IsSuccessStatusCode then
53− return JsonDocument.Parse(text)
54− else
55− if response.StatusCode = System.Net.HttpStatusCode.Unauthorized then
56− return raise Unauthorized
57− let code =
58− try
59− use d = JsonDocument.Parse(text)
60− match d.RootElement.TryGetProperty("error") with
61− | true, e -> e.GetString()
62− | _ -> string (int response.StatusCode)
63− with _ -> string (int response.StatusCode)
64− return raise (ApiError $"{url}: {code}")
65− }
66−
67− let private postJson<'T> httpClient url body token =
68− async {
69− use! doc = sendRequest httpClient url body token
70− return doc.RootElement.Deserialize<'T>(jsonOpts)
71− }
72−
73− let authenticate (serverUrl: string) (identity: Crypto.Identity) =
74− async {
75− let body = JsonSerializer.Serialize({| pubkey = identity.PubKeyHex |})
76− use! challengeDoc = sendRequest client $"{serverUrl}/auth/challenge" body None
77− let challenge = challengeDoc.RootElement.GetProperty("challenge").GetString()
78− let sigHex = Crypto.signChallenge identity.PrivKey challenge
79− let body = JsonSerializer.Serialize({| pubkey = identity.PubKeyHex; challenge = challenge; signature = sigHex |})
80− use! tokenDoc = sendRequest client $"{serverUrl}/auth/verify" body None
81− let token = tokenDoc.RootElement.GetProperty("token").GetString()
82− if String.IsNullOrEmpty(token) then raise (ApiError "Authentication rejected by server")
83− return token
84− }
85−
86− let sendMessage (serverUrl: string) (token: string) (toHex: string) (blobHex: string) =
87− let body =
88− JsonSerializer.Serialize(
89− {| messages = [| {| ``to`` = toHex; encryptedBlob = blobHex |} |] |})
90− async {
91− try
92− use! _doc = sendRequest client $"{serverUrl}/messages" body (Some token)
93− ()
94− with ApiError msg when msg.EndsWith(": self_send") ->
95− return raise (ServerRejected "Message rejected by server")
96− }
97−
98− let poll (serverUrl: string) (token: string) (cursor: int64) =
99− let body = JsonSerializer.Serialize({| cursor = cursor |})
100− postJson<PollResponse> pollClient $"{serverUrl}/poll" body (Some token)
101−
client/App.fsdeleted−732
@@ -1,732 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.IO
5−open Avalonia.Media
6−open Avalonia.Themes.Fluent
7−open Fabulous
8−open Fabulous.Avalonia
9−
10−open type Fabulous.Avalonia.View
11−
12−module App =
13−
14− open Store
15− open Crypto
16− open ApiClient
17− open AppTypes
18− open ViewSetup
19− open ViewSettings
20− open ViewConversations
21− open ViewChat
22− open ViewAddContact
23− open ViewEditProfile
24− open Protocol
25−
26− // ── Helpers ──
27−
28− let private tryParsePubkey (input: string) =
29− let input = input.Trim()
30−
31− Phonemic.fromOb input
32− |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
33− |> Option.filter (fun bytes -> bytes.Length = 32)
34−
35− let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
36− let existing = messagesFor pk messages
37−
38− if existing |> List.exists (fun m -> m.Id = msg.Id) then
39− messages
40− else
41− Map.add pk (existing @ [ msg ]) messages
42−
43− let private markDelivered (pk: string) (ackIds: string list) (messages: Map<string, ChatMessage list>) =
44− let ackSet = Set.ofList ackIds
45−
46− messages
47− |> Map.change
48− pk
49− (Option.map (
50− List.map (fun m ->
51− match m.Direction with
52− | Outgoing DeliveryStatus.Sent when Set.contains m.Id ackSet ->
53− { m with
54− Direction = Outgoing DeliveryStatus.Delivered }
55− | _ -> m)
56− ))
57−
58− let private newContact pubkey nickname : Contact =
59− { Pubkey = pubkey
60− Nickname = nickname
61− DisplayName = truncKey pubkey
62− Bio = ""
63− Photo = None
64− AddedDate = System.DateTimeOffset.UtcNow }
65−
66− let private withProfile (p: Profile) (c: Contact) =
67− { c with
68− DisplayName = p.DisplayName
69− Bio = p.Bio
70− Photo = p.Photo }
71−
72− let private applyEvent (m: Model) (evt: IncomingEvent) =
73− match evt.Envelope with
74− | Envelope.TextMessage(id, body) ->
75− let message =
76− { Id = id
77− Body = body
78− Timestamp = evt.Timestamp
79− Direction = Incoming }
80−
81− { m with
82− Contacts =
83− if Map.containsKey evt.Sender m.Contacts then
84− m.Contacts
85− else
86− Map.add evt.Sender (newContact evt.Sender "") m.Contacts
87− Messages = m.Messages |> appendMessage evt.Sender message }
88− | Envelope.DeliveryAck ackIds ->
89− { m with
90− Messages = m.Messages |> markDelivered evt.Sender ackIds }
91− | Envelope.ProfileMessage profile ->
92− { m with
93− Contacts =
94− m.Contacts
95− |> Map.change evt.Sender (fun c ->
96− let contact = c |> Option.defaultWith (fun () -> newContact evt.Sender "")
97− Some(withProfile profile contact)) }
98−
99− let private trySession model =
100− match model.Auth with
101− | Identified(id, Online token) ->
102− Some
103− { Url = model.ServerUrl
104− Token = token
105− Identity = id }
106− | _ -> None
107−
108− let private setConn status model =
109− match model.Auth with
110− | Identified(id, _) ->
111− { model with
112− Auth = Identified(id, status) }
113− | NoIdentity -> model
114−
115− let private saveCmdMsg model : CmdMsg =
116− CmdSaveData(
117− { Contacts = model.Contacts
118− Messages = model.Messages
119− ServerUrl = model.ServerUrl
120− PollCursor = model.PollCursor }
121− : Data
122− )
123−
124− // ── Init ──
125−
126− let init () =
127− { Page = Setup
128− Auth = NoIdentity
129− ServerUrl = "http://localhost:8080"
130− PollStatus = ""
131− Contacts = Map.empty
132− Messages = Map.empty
133− Error = None
134− PollCursor = 0L
135− Profile = None
136− FlushingOutbox = false
137− PollRetries = 0
138− KeyboardHeight = 0.
139− SafeAreaTop = 0.
140− SafeAreaBottom = 0. },
141− [ CmdLoadState; CmdSubscribeKeyboard; CmdSubscribeSafeArea ]
142−
143− // ── Update ──
144−
145− let update msg model =
146− match msg with
147− | SetPage page -> { model with Page = page }, []
148−
149− | GenIdentity ->
150− let identity = Crypto.generateIdentity ()
151−
152− { model with
153− Auth = Identified(identity, Offline)
154− Page = EditProfile("", "", None) },
155− [ CmdSaveIdentity identity ]
156−
157− | SetServerUrl url -> { model with ServerUrl = url }, []
158−
159− | DoConnect ->
160− match model.Auth with
161− | Identified(id, _) -> model |> setConn Connecting, [ CmdConnect(model.ServerUrl, id) ]
162− | NoIdentity ->
163− { model with
164− Error = Some "No identity generated" },
165− []
166−
167− | AuthOk token ->
168− match model.Auth with
169− | Identified(id, Connecting) ->
170− let session =
171− { Url = model.ServerUrl
172− Token = token
173− Identity = id }
174−
175− { model with
176− Auth = Identified(id, Online token)
177− Page =
178− match model.Page with
179− | Setup -> Conversations
180− | p -> p
181− Error = None
182− PollStatus = "polling..."
183− FlushingOutbox = true },
184− [ CmdPoll(session, model.PollCursor, 0); CmdFlushOutbox session ]
185− | _ -> model, []
186−
187− | AuthErr err ->
188− { setConn Offline model with
189− Error = Some err
190− FlushingOutbox = false
191− PollRetries = 0 },
192− []
193−
194− | TokenExpired ->
195− match model.Auth with
196− | Identified(id, Online _) ->
197− let m = setConn Connecting model
198−
199− { m with
200− FlushingOutbox = false
201− PollRetries = 0 },
202− [ CmdConnect(model.ServerUrl, id) ]
203− | _ -> model, []
204−
205− | DoDisconnect ->
206− { setConn Offline model with
207− PollStatus = ""
208− FlushingOutbox = false
209− PollRetries = 0 },
210− []
211−
212− | DoSend ->
213− match model.Page with
214− | Chat(pk, compose) when compose.Trim() <> "" ->
215− let body = compose.Trim()
216− let id = Guid.NewGuid().ToString()
217−
218− let outMsg =
219− { Id = id
220− Body = body
221− Timestamp = DateTimeOffset.UtcNow
222− Direction = Outgoing DeliveryStatus.Sent }
223−
224− let neverSentTo =
225− model.Messages
226− |> messagesFor pk
227− |> List.exists (fun m -> match m.Direction with Outgoing _ -> true | _ -> false)
228− |> not
229−
230− let model' =
231− { model with
232− Page = Chat(pk, "")
233− Messages = model.Messages |> appendMessage pk outMsg }
234−
235− model',
236− [ CmdEnqueue(pk, Envelope.TextMessage(id, body))
237− match model.Profile with
238− | Some profile when neverSentTo -> CmdEnqueue(pk, Envelope.ProfileMessage profile)
239− | _ -> ()
240− saveCmdMsg model' ]
241− | _ -> model, []
242−
243− | StartFlush ->
244− if model.FlushingOutbox then
245− eprintfn "[skrepka] StartFlush: already flushing, skipping"
246− model, []
247− else
248− match trySession model with
249− | Some session ->
250− eprintfn "[skrepka] StartFlush: dispatching flush"
251− { model with FlushingOutbox = true }, [ CmdFlushOutbox session ]
252− | None ->
253− eprintfn "[skrepka] StartFlush: no session"
254− model, []
255−
256− | FlushResult sent ->
257− if sent then
258− match trySession model with
259− | Some session -> model, [ CmdFlushOutbox session ]
260− | None -> { model with FlushingOutbox = false }, []
261− else
262− { model with FlushingOutbox = false }, []
263−
264− | PollResult(events, status, newCursor) ->
265− let retries =
266− if events.IsEmpty && status.StartsWith("poll error") then
267− model.PollRetries + 1
268− else
269− 0
270−
271− let model' =
272− events
273− |> List.fold
274− applyEvent
275− { model with
276− PollStatus = status
277− PollCursor = newCursor
278− PollRetries = retries }
279−
280− model',
281− [ match trySession model' with
282− | Some session -> CmdPoll(session, model'.PollCursor, model'.PollRetries)
283− | None -> ()
284− if not events.IsEmpty then
285− saveCmdMsg model' ]
286−
287− | DoSaveContact ->
288− match model.Page with
289− | AddContact(cpk) when cpk <> "" ->
290− match tryParsePubkey cpk with
291− | Some bytes ->
292− let hex = Crypto.toHex bytes
293−
294− match Map.tryFind hex model.Contacts with
295− | Some e -> model, []
296− | None ->
297− let contact = newContact hex cpk
298−
299− let model' =
300− { model with
301− Contacts = Map.add hex contact model.Contacts
302− Page = Conversations }
303−
304− model', [ saveCmdMsg model' ]
305− | None ->
306− { model with
307− Error = Some "Invalid public key" },
308− []
309− | _ -> model, []
310−
311− | CopyPubKey ->
312− match model.Auth with
313− | Identified(id, _) -> model, [ CmdCopyToClipboard(hexToOb id.PubKeyHex) ]
314− | NoIdentity -> model, []
315−
316− | DismissError -> { model with Error = None }, []
317−
318− | DoPickPhoto -> model, [ CmdPickPhoto ]
319−
320− | PhotoPicked(Some _ as photo) ->
321− match model.Page with
322− | EditProfile(name, bio, _) ->
323− { model with
324− Page = EditProfile(name, bio, photo) },
325− []
326− | _ -> model, []
327− | PhotoPicked None -> model, []
328−
329− | DoSaveProfile ->
330− match model.Page with
331− | EditProfile(name, bio, photo) ->
332− let profile: Profile =
333− { DisplayName = name
334− Bio = bio
335− Photo = photo }
336−
337− { model with
338− Profile = Some profile
339− Page = Settings },
340− [ CmdSaveProfile profile
341− for pk in model.Contacts.Keys do
342− CmdEnqueue(pk, Envelope.ProfileMessage profile) ]
343− | _ -> model, []
344−
345− | StateLoaded(Some identity, Some data, profile) ->
346− let model' =
347− { model with
348− Auth = Identified(identity, Connecting)
349− Contacts = data.Contacts
350− Messages = data.Messages
351− ServerUrl =
352− if data.ServerUrl <> "" then
353− data.ServerUrl
354− else
355− model.ServerUrl
356− PollCursor = data.PollCursor
357− Profile = profile
358− Page = Conversations }
359−
360− model', [ CmdConnect(model'.ServerUrl, identity) ]
361− | StateLoaded(Some identity, None, profile) ->
362− { model with
363− Auth = Identified(identity, Offline)
364− Profile = profile
365− Page = Settings },
366− []
367− | StateLoaded(None, _, _) -> model, []
368−
369− | Search _ -> model, []
370−
371− | KeyboardHeightChanged h -> { model with KeyboardHeight = h }, []
372− | SafeAreaChanged(top, bottom) -> { model with SafeAreaTop = top; SafeAreaBottom = bottom }, []
373−
374− | DoScanQR -> model, [ CmdScanQR ]
375−
376− | QRScanned(Some text) ->
377− match model.Page with
378− | AddContact _ -> { model with Page = AddContact(text) }, []
379− | _ -> model, []
380−
381− | QRScanned None -> model, []
382−
383− // ── mapCmd ──
384−
385− let private log msg = eprintfn $"[skrepka] {msg}"
386−
387− let private getClipboard () =
388− match Avalonia.Application.Current with
389− | null -> None
390− | app ->
391− match app.ApplicationLifetime with
392− | :? Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime as desktop ->
393− desktop.MainWindow
394− |> Option.ofObj
395− |> Option.bind (fun w -> w.Clipboard |> Option.ofObj)
396− | :? Avalonia.Controls.ApplicationLifetimes.ISingleViewApplicationLifetime as singleView ->
397− singleView.MainView
398− |> Option.ofObj
399− |> Option.bind (fun v -> Avalonia.Controls.TopLevel.GetTopLevel(v) |> Option.ofObj)
400− |> Option.bind (fun tl -> tl.Clipboard |> Option.ofObj)
401− | _ -> None
402−
403− let private getStorageProvider () =
404− match Avalonia.Application.Current with
405− | null -> None
406− | app ->
407− match app.ApplicationLifetime with
408− | :? Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime as desktop ->
409− desktop.MainWindow |> Option.ofObj |> Option.map (fun w -> w.StorageProvider)
410− | :? Avalonia.Controls.ApplicationLifetimes.ISingleViewApplicationLifetime as singleView ->
411− singleView.MainView
412− |> Option.ofObj
413− |> Option.bind (fun v -> Avalonia.Controls.TopLevel.GetTopLevel(v) |> Option.ofObj)
414− |> Option.map (fun tl -> tl.StorageProvider)
415− | _ -> None
416−
417− let private cropToSquarePng (imageBytes: byte[]) =
418− use skBmp = SkiaSharp.SKBitmap.Decode(imageBytes)
419− let size = min skBmp.Width skBmp.Height
420− let x = (skBmp.Width - size) / 2
421− let y = (skBmp.Height - size) / 2
422− use cropped = new SkiaSharp.SKBitmap(size, size)
423−
424− skBmp.ExtractSubset(cropped, SkiaSharp.SKRectI(x, y, x + size, y + size))
425− |> ignore
426−
427− use img = SkiaSharp.SKImage.FromBitmap(cropped)
428− use data = img.Encode(SkiaSharp.SKEncodedImageFormat.Png, 90)
429− data.ToArray()
430−
431−#if MOBILE
432− let private scanQRCmd =
433− Cmd.ofEffect (fun dispatch ->
434− task {
435− try
436− let! result = Skrepka.iOS.QRScanner.scan ()
437− dispatch (QRScanned result)
438− with ex ->
439− log $"QR scan error: {ex.Message}"
440− dispatch (QRScanned None)
441− }
442− |> ignore)
443−
444− let private pickPhotoCmd =
445− Cmd.ofEffect (fun dispatch ->
446− task {
447− try
448− let! bytes = Skrepka.iOS.PhotoPicker.pick ()
449−
450− match bytes with
451− | Some b -> dispatch (PhotoPicked(Some(cropToSquarePng b)))
452− | None -> dispatch (PhotoPicked None)
453− with ex ->
454− log $"photo pick error: {ex.Message}"
455− dispatch (PhotoPicked None)
456− }
457− |> ignore)
458−#else
459− let private scanQRCmd =
460− Cmd.ofEffect (fun dispatch ->
461− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
462− task {
463− try
464− match getStorageProvider () with
465− | Some sp ->
466− let opts =
467− Avalonia.Platform.Storage.FilePickerOpenOptions(
468− AllowMultiple = false,
469− Title = "Select QR Code Image",
470− FileTypeFilter =
471− [ Avalonia.Platform.Storage.FilePickerFileType(
472− "Images",
473− Patterns = [ "*.png"; "*.jpg"; "*.jpeg"; "*.gif"; "*.bmp" ]
474− ) ]
475− )
476−
477− let! files = sp.OpenFilePickerAsync(opts)
478−
479− match files |> Seq.tryHead with
480− | None -> dispatch (QRScanned None)
481− | Some file ->
482− use! stream = file.OpenReadAsync()
483− use ms = new MemoryStream()
484− do! stream.CopyToAsync(ms)
485−
486− use bitmap = SkiaSharp.SKBitmap.Decode(ms.ToArray())
487− let reader = ZXing.SkiaSharp.BarcodeReader()
488− let result = reader.Decode(bitmap)
489−
490− if isNull result then
491− dispatch (QRScanned None)
492− else
493− dispatch (QRScanned(Some result.Text))
494− | None -> dispatch (QRScanned None)
495− with ex ->
496− log $"QR scan error: {ex.Message}"
497− dispatch (QRScanned None)
498− }
499− :> System.Threading.Tasks.Task)
500− |> ignore)
501−
502− let private pickPhotoCmd =
503− Cmd.ofEffect (fun dispatch ->
504− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
505− task {
506− try
507− match getStorageProvider () with
508− | Some sp ->
509− let opts =
510− Avalonia.Platform.Storage.FilePickerOpenOptions(
511− AllowMultiple = false,
512− Title = "Pick Photo",
513− FileTypeFilter =
514− [ Avalonia.Platform.Storage.FilePickerFileType(
515− "Images",
516− Patterns = [ "*.png"; "*.jpg"; "*.jpeg"; "*.gif"; "*.bmp" ]
517− ) ]
518− )
519−
520− let! files = sp.OpenFilePickerAsync(opts)
521−
522− match files |> Seq.tryHead with
523− | None -> dispatch (PhotoPicked None)
524− | Some file ->
525− use! stream = file.OpenReadAsync()
526− use ms = new MemoryStream()
527− do! stream.CopyToAsync(ms)
528− dispatch (PhotoPicked(Some(cropToSquarePng (ms.ToArray()))))
529− | None -> dispatch (PhotoPicked None)
530− with ex ->
531− log $"photo pick error: {ex.Message}"
532− dispatch (PhotoPicked None)
533− }
534− :> System.Threading.Tasks.Task)
535− |> ignore)
536−#endif
537−
538− let mapCmd cmdMsg =
539− match cmdMsg with
540− | CmdConnect(url, identity) ->
541− Cmd.OfAsync.either (fun (url, id) -> authenticate url id) (url, identity) AuthOk (fun ex ->
542− AuthErr ex.Message)
543−
544− | CmdEnqueue(recipientHex, envelope) ->
545− Cmd.ofEffect (fun dispatch ->
546− async {
547− let ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
548− let json = serializeEnvelope ts envelope
549− do! Store.enqueueOutbox recipientHex json
550− dispatch StartFlush
551− }
552− |> Async.Start)
553−
554− | CmdFlushOutbox session ->
555− Cmd.ofEffect (fun dispatch ->
556− async {
557− match! Store.peekOutbox () with
558− | Some item ->
559− log $"flush: sending to {item.RecipientHex.[..7]}..."
560− try
561− let blobOpt =
562− Crypto.encrypt
563− session.Identity.PrivKey
564− (Crypto.fromHex item.RecipientHex)
565− item.EnvelopeJson
566−
567− match blobOpt with
568− | Some blob ->
569− log $"flush: encrypted, posting to {session.Url}/messages"
570− do! sendMessage session.Url session.Token item.RecipientHex (Crypto.toHex blob)
571− log "flush: sent OK"
572− do! Store.dequeueOutbox item.Id
573− dispatch (FlushResult true)
574− | None ->
575− log $"outbox encrypt failed, dropping message to {item.RecipientHex}"
576− do! Store.dequeueOutbox item.Id
577− dispatch (FlushResult true)
578− with
579− | Unauthorized ->
580− log "outbox unauthorized, reauthenticating"
581− dispatch TokenExpired
582− | ServerRejected msg ->
583− log $"outbox rejected, dropping: {msg}"
584− do! Store.dequeueOutbox item.Id
585− dispatch (FlushResult true)
586− | ex ->
587− log $"outbox send error: {ex.Message}"
588− do! Async.Sleep Constants.outboxRetryDelayMs
589− dispatch (FlushResult true)
590− | None -> dispatch (FlushResult false)
591− }
592− |> Async.Start)
593−
594− | CmdPoll(session, cursor, retries) ->
595− Cmd.ofEffect (fun dispatch ->
596− async {
597− log $"poll start cursor={cursor}"
598−
599− try
600− let! response = poll session.Url session.Token cursor
601− log $"poll response: {response.Events.Length} events, cursor={response.Cursor}"
602−
603− let results =
604− response.Events
605− |> Array.map (fun evt -> decryptEvent session.Identity.PrivKey evt)
606−
607− let events = results |> Array.choose Result.toOption |> Array.toList
608−
609− let errors =
610− results
611− |> Array.choose (function
612− | Error e -> Some e
613− | _ -> None)
614− |> Array.toList
615−
616− for e in errors do
617− log $"decrypt error: {e}"
618−
619− if not events.IsEmpty then
620− let acksBySender =
621− events
622− |> List.choose (fun evt ->
623− match evt.Envelope with
624− | Envelope.TextMessage(id, _) -> Some(evt.Sender, id)
625− | _ -> None)
626− |> List.groupBy fst
627− |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
628−
629− for sender, msgIds in acksBySender do
630− try
631− do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
632− with ex ->
633− log $"delivery ack error: {ex.Message}"
634−
635− let chatCount =
636− events
637− |> List.sumBy (fun e ->
638− match e.Envelope with
639− | Envelope.TextMessage _ -> 1
640− | _ -> 0)
641−
642− let status =
643− $"evts:{response.Events.Length} msgs:{chatCount} errs:{errors.Length}"
644− + (match List.tryHead errors with
645− | Some e -> $" [{e}]"
646− | None -> "")
647−
648− dispatch (PollResult(events, status, response.Cursor))
649− with
650− | :? TimeoutException ->
651− log "poll timeout"
652− dispatch (PollResult([], "polling...", cursor))
653− | Unauthorized ->
654− log "poll unauthorized, reauthenticating"
655− dispatch TokenExpired
656− | ex ->
657− log $"poll error: {ex.Message}"
658− let delay = min (Constants.pollRetryBaseMs * (pown 2 retries)) 30000
659− do! Async.Sleep delay
660− dispatch (PollResult([], $"poll error: {ex.Message}", cursor))
661− }
662− |> Async.Start)
663−
664− | CmdCopyToClipboard text ->
665− Cmd.ofEffect (fun _ ->
666− match getClipboard () with
667− | Some clipboard -> clipboard.SetTextAsync(text) |> ignore
668− | None -> ())
669−
670− | CmdLoadState ->
671− Cmd.OfAsync.perform
672− (fun () ->
673− async {
674− match! Store.loadIdentity () with
675− | Some id ->
676− let! data = Store.loadData ()
677− let! profile = Store.loadProfile ()
678− return (Some id, data, profile)
679− | None -> return (None, None, None)
680− })
681− ()
682− StateLoaded
683−
684− | CmdSaveIdentity identity -> Cmd.ofEffect (fun _ -> Store.saveIdentity identity |> Async.Start)
685−
686− | CmdSaveData data -> Cmd.ofEffect (fun _ -> Store.saveData data |> Async.Start)
687−
688− | CmdPickPhoto -> pickPhotoCmd
689−
690− | CmdScanQR -> scanQRCmd
691−
692− | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile |> Async.Start)
693−
694− | CmdSubscribeKeyboard ->
695− Cmd.ofEffect (fun dispatch -> Keyboard.heightChanged.Add(fun h -> dispatch (KeyboardHeightChanged h)))
696−
697− | CmdSubscribeSafeArea ->
698− Cmd.ofEffect (fun dispatch -> SafeArea.insetsChanged.Add(fun (t, b) -> dispatch (SafeAreaChanged(t, b))))
699−
700− // ── View ──
701−
702− let private pageContent model =
703− let page =
704− match model.Page with
705− | Setup -> AnyView(viewSetup ())
706− | Settings -> AnyView(viewSettings model)
707− | Conversations -> AnyView(viewConversations model)
708− | Chat(pk, compose) -> AnyView(viewChat model pk compose)
709− | AddContact(pk) -> AnyView(viewAddContact model pk)
710− | EditProfile(displayName, bio, photo) -> AnyView(viewEditProfile model displayName bio photo)
711−
712− Border(page)
713− .background(SolidColorBrush(Colors.White))
714− .padding (Avalonia.Thickness(0., model.SafeAreaTop, 0., model.KeyboardHeight))
715−
716− let view model =
717−#if MOBILE
718− SingleViewApplication(pageContent model)
719−#else
720− DesktopApplication() {
721− Window(pageContent model)
722− .title("Skrepka")
723− .icon("avares://Skrepka/Assets/Images/logo.png")
724− .width(450.)
725− .height (750.)
726− }
727−#endif
728−
729− let program = Program.statefulWithCmdMsg init update mapCmd |> Program.withView view
730−
731− let create () =
732− FabulousAppBuilder.Configure(FluentTheme, program)
client/AppTypes.fsdeleted−149
@@ -1,149 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open Avalonia.Media
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module AppTypes =
10−
11− open Store
12− open Crypto
13−
14− // ── Domain Types ──
15−
16− type Page =
17− | Setup
18− | Conversations
19− | Chat of pubkey: string * compose: string
20− | AddContact of pubkey: string
21− | Settings
22− | EditProfile of displayName: string * bio: string * photo: byte[] option
23−
24− type Session =
25− { Url: string
26− Token: string
27− Identity: Identity }
28−
29− type ConnStatus =
30− | Offline
31− | Connecting
32− | Online of token: string
33−
34− type AuthState =
35− | NoIdentity
36− | Identified of identity: Identity * conn: ConnStatus
37−
38− [<RequireQualifiedAccess>]
39− type Envelope =
40− | TextMessage of id: string * body: string
41− | DeliveryAck of ackIds: string list
42− | ProfileMessage of profile: Profile
43−
44− type IncomingEvent =
45− { Sender: string
46− Timestamp: DateTimeOffset
47− Envelope: Envelope }
48−
49− // ── Model ──
50−
51− type Model =
52− { Page: Page
53− Auth: AuthState
54− ServerUrl: string
55− Contacts: Map<string, Contact>
56− PollStatus: string
57− Messages: Map<string, ChatMessage list>
58− Error: string option
59− PollCursor: int64
60− Profile: Profile option
61− FlushingOutbox: bool
62− PollRetries: int
63− KeyboardHeight: float
64− SafeAreaTop: float
65− SafeAreaBottom: float }
66−
67− // ── Msg ──
68−
69− type Msg =
70− | SetPage of Page
71− | GenIdentity
72− | SetServerUrl of string
73− | DoConnect
74− | AuthOk of string
75− | AuthErr of string
76− | DoDisconnect
77− | DoSend
78− | DoSaveContact
79− | PollResult of events: IncomingEvent list * status: string * cursor: int64
80− | CopyPubKey
81− | DismissError
82− | StateLoaded of Identity option * Data option * Profile option
83− | DoPickPhoto
84− | PhotoPicked of byte[] option
85− | DoSaveProfile
86− | StartFlush
87− | FlushResult of sent: bool
88− | Search of string
89− | TokenExpired
90− | KeyboardHeightChanged of float
91− | SafeAreaChanged of top: float * bottom: float
92− | DoScanQR
93− | QRScanned of string option
94−
95− // ── CmdMsg ──
96−
97− type CmdMsg =
98− | CmdConnect of url: string * identity: Identity
99− | CmdEnqueue of recipientHex: string * envelope: Envelope
100− | CmdFlushOutbox of session: Session
101− | CmdPoll of session: Session * cursor: int64 * retries: int
102− | CmdCopyToClipboard of string
103− | CmdLoadState
104− | CmdSaveIdentity of Identity
105− | CmdSaveData of Data
106− | CmdPickPhoto
107− | CmdSaveProfile of Profile
108− | CmdSubscribeKeyboard
109− | CmdSubscribeSafeArea
110− | CmdScanQR
111−
112− // ── Shared Helpers ──
113−
114− let hexToOb (hex: string) = Crypto.fromHex hex |> Phonemic.toOb
115−
116− let truncKey (hex: string) =
117− let ob = hexToOb hex
118− let parts = ob.Split('-')
119−
120− if parts.Length <= 4 then
121− ob
122− else
123− $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
124−
125− let contactName (contacts: Map<string, Contact>) (pk: string) =
126− match Map.tryFind pk contacts with
127− | Some c -> c.DisplayName
128− | _ -> pk
129−
130− let messagesFor (pk: string) (messages: Map<string, ChatMessage list>) =
131− messages |> Map.tryFind pk |> Option.defaultValue []
132−
133− let connStatusLabel =
134− function
135− | NoIdentity
136− | Identified(_, Offline) -> "OFFLINE"
137− | Identified(_, Connecting) -> "CONNECTING"
138− | Identified(_, Online _) -> "ONLINE"
139−
140− // ── Shared View ──
141−
142− let viewErrorBanner (error: string option) =
143− VStack(4.) {
144− match error with
145− | Some err ->
146− TextBlock(err).foreground(SolidColorBrush(Colors.Red)).fontSize (12.)
147− Button("Dismiss", DismissError)
148− | None -> ()
149− }
client/Assets/Fonts/OpenSans-Regular.ttfdeleted

Binary file not shown.

client/Assets/Fonts/OpenSans-Semibold.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Black.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Bold.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-ExtraBold.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-ExtraLight.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Light.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Medium.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-Regular.ttfdeleted

Binary file not shown.

client/Assets/Fonts/Unbounded-SemiBold.ttfdeleted

Binary file not shown.

client/Assets/Images/left.pngdeleted

Binary file not shown.

client/Assets/Images/logo.pngdeleted

Binary file not shown.

client/Assets/Images/send.pngdeleted

Binary file not shown.

client/Assets/Images/user.pngdeleted

Binary file not shown.

client/Buttons.fsdeleted−209
@@ -1,209 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module Buttons =
10− open Avalonia.Media.Transformation
11− open System
12−
13− type ButtonPriority =
14− | Primary
15− | Secondary
16−
17− let private translate x y =
18− let b = TransformOperations.CreateBuilder 1
19− b.AppendTranslate(x, y)
20− b.Build() :> ITransform
21−
22− let private shadowRect offset =
23− Rectangle()
24− .fill(SolidColorBrush(Colors.Black))
25− .renderTransform(translate offset offset)
26− .transition (
27− TransformOperationsTransition(
28− Avalonia.Visual.RenderTransformProperty,
29− TimeSpan.FromMilliseconds Constants.animationDuration
30− )
31− .easing (Constants.easing)
32− )
33−
34− let private borderRect () =
35− Rectangle().stroke(Colors.Black).strokeThickness (4.)
36−
37− let private textButton fontSize pressOffset restOffset margin priority (text: string) (msg: 'msg) disabled =
38− Component($"{text}-{disabled}") {
39− let! pressed = Context.State(false)
40− let! animatedIn = Context.State(false)
41−
42− let offset =
43− if disabled then 0.
44− elif not animatedIn.Current then
45− Avalonia.Threading.Dispatcher.UIThread.Post(fun () -> animatedIn.Set true)
46− 0.
47− elif pressed.Current then pressOffset
48− else restOffset
49−
50− (Grid() {
51− shadowRect offset
52−
53− Rectangle()
54− .fill (
55− match priority with
56− | Primary -> SolidColorBrush(Constants.accentColor)
57− | Secondary -> SolidColorBrush(Colors.White)
58− )
59−
60− TextBlock(text)
61− .foreground(
62− match priority with
63− | Primary -> SolidColorBrush(Colors.White)
64− | Secondary -> SolidColorBrush(Colors.Black)
65−
66− )
67− .fontSize(fontSize)
68− .fontFamily(FontFamily(Constants.fontFamily))
69− .fontWeight(FontWeight.Bold)
70− .center()
71− .margin (8.)
72−
73− borderRect ()
74− })
75− .background(Brushes.Transparent)
76− .onPointerPressed(fun _ -> if not disabled then pressed.Set true)
77− .onPointerReleased(fun _ -> pressed.Set false)
78− .onPointerExited(fun _ -> pressed.Set false)
79− .margin(margin)
80− .onTapped (fun _ -> msg)
81− }
82−
83− let button priority text msg = textButton 24. 4. 8. 16. priority text msg false
84− let smallButton priority text msg = textButton 16. 2. 4. 8. priority text msg false
85− let disabledButton priority text msg = textButton 24. 4. 8. 16. priority text msg true
86− let disabledSmallButton priority text msg = textButton 16. 2. 4. 8. priority text msg true
87−
88− let imageButton (img: byte[] option) (msg: 'msg) =
89− let key =
90− match img with
91− | None -> "img-button"
92− | Some i -> $"img-button-{hash i}"
93−
94− Component(key) {
95− let! pressed = Context.State(false)
96− let offset = if pressed.Current then 4. else 8.
97−
98− (Grid() {
99− shadowRect offset
100−
101− Rectangle().fill (SolidColorBrush(Colors.White))
102−
103− match img with
104− | None ->
105− ViewBox(
106− (VStack(32) {
107− Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
108− .width(80.)
109− .margin (0., 20., 0., 0.)
110−
111− Label("Upload")
112− .fontFamily(FontFamily(Constants.fontFamily))
113− .fontWeight(FontWeight.Bold)
114− .fontSize(24.)
115− .centerContentHorizontal ()
116− })
117− )
118− .margin (24.)
119−
120− | Some i -> Image(Styles.cachedBitmap i, Stretch.UniformToFill).clipToBounds (true)
121−
122− borderRect ()
123− })
124− .background(Brushes.Transparent)
125− .onPointerPressed(fun _ -> pressed.Set true)
126− .onPointerReleased(fun _ -> pressed.Set false)
127− .onPointerExited(fun _ -> pressed.Set false)
128− .width(200.)
129− .height(200.)
130− .margin(16.)
131− .onTapped (fun _ -> msg)
132− }
133−
134− let smallImageButton (asset: string) (msg: 'msg) =
135− Component($"img-button-{asset}") {
136− let! pressed = Context.State(false)
137− let offset = if pressed.Current then 2. else 4.
138−
139− (Grid() {
140− shadowRect offset
141−
142− Rectangle().fill (SolidColorBrush(Colors.White))
143−
144− Image(asset, Stretch.Uniform).width(24.).height(24.).center ()
145−
146− borderRect ()
147− })
148− .width(48.)
149− .height(48.)
150− .background(Brushes.Transparent)
151− .onPointerPressed(fun _ -> pressed.Set true)
152− .onPointerReleased(fun _ -> pressed.Set false)
153− .onPointerExited(fun _ -> pressed.Set false)
154− .margin(8.)
155− .onTapped (fun _ -> msg)
156− }
157−
158− let backButton (msg: 'msg) =
159− Component("back-button") {
160− let! pressed = Context.State(false)
161− let offset = if pressed.Current then 2. else 4.
162−
163− (Grid() {
164− shadowRect offset
165−
166− Rectangle().fill (SolidColorBrush(Colors.White))
167−
168− Image("avares://Skrepka/Assets/Images/left.png", Stretch.Uniform).width(24.).height(24.).center ()
169−
170− borderRect ()
171− })
172− .width(48.)
173− .height(48.)
174− .background(Brushes.Transparent)
175− .onPointerPressed(fun _ -> pressed.Set true)
176− .onPointerReleased(fun _ -> pressed.Set false)
177− .onPointerExited(fun _ -> pressed.Set false)
178− .margin(8.)
179− .onTapped (fun _ -> msg)
180− }
181−
182− let smallTextButton (text: string) (msg: 'msg) =
183− Component($"small-button-{text}") {
184− let! pressed = Context.State(false)
185− let offset = if pressed.Current then 2. else 4.
186−
187− (Grid() {
188− shadowRect offset
189−
190− Rectangle().fill (SolidColorBrush(Colors.White))
191−
192− TextBlock(text)
193− .fontFamily(Constants.fontFamily)
194− .fontWeight(FontWeight.Bold)
195− .fontSize(28.)
196− .textAlignment(TextAlignment.Center)
197− .center ()
198−
199− borderRect ()
200− })
201− .width(48.)
202− .height(48.)
203− .background(Brushes.Transparent)
204− .onPointerPressed(fun _ -> pressed.Set true)
205− .onPointerReleased(fun _ -> pressed.Set false)
206− .onPointerExited(fun _ -> pressed.Set false)
207− .margin(8.)
208− .onTapped (fun _ -> msg)
209− }
client/Constants.fsdeleted−16
@@ -1,16 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−
5−module Constants =
6− let accentColor = Color.FromRgb(98uy, 54uy, 255uy)
7− let animationDuration: int64 = 60
8− let easing = Avalonia.Animation.Easings.CubicEaseOut()
9− let fontFamily = "avares://Skrepka/Assets/Fonts#Unbounded"
10− let hkdfInfo = "skrepka-v1"
11− let profileId = "me"
12− let settingsId = "settings"
13− let maxBitmapCacheSize = 128
14− let pollRetryBaseMs = 3000
15− let outboxRetryDelayMs = 3000
16− let maxMessages = 200
client/Crypto.fsdeleted−245
@@ -1,245 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.Text
5−open System.Security.Cryptography
6−open K4os.Compression.LZ4
7−
8−module Crypto =
9−
10− type Identity = { PrivKey: byte[]; PubKeyHex: string }
11−
12− let toHex (bytes: byte[]) : string =
13− Convert.ToHexString(bytes).ToLowerInvariant()
14−
15− let fromHex (hex: string) : byte[] =
16− Convert.FromHexString(hex)
17−
18− let tryFromHex (hex: string) : byte[] option =
19− try Some(Convert.FromHexString(hex))
20− with :? FormatException -> None
21−
22− // ── Platform-specific primitives ──
23−
24−#if MOBILE
25− open System.Runtime.InteropServices
26−
27− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
28− extern int private sodium_init()
29−
30− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
31− extern void private randombytes_buf(byte[] buf, unativeint size)
32−
33− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
34− extern int private crypto_sign_keypair(byte[] pk, byte[] sk)
35−
36− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
37− extern int private crypto_sign_detached(byte[] out, nativeint& siglen, byte[] m, uint64 mlen, byte[] sk)
38−
39− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
40− extern int private crypto_sign_verify_detached(byte[] s, byte[] m, uint64 mlen, byte[] pk)
41−
42− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
43− extern int private crypto_sign_ed25519_pk_to_curve25519(byte[] x25519_pk, byte[] ed25519_pk)
44−
45− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
46− extern int private crypto_sign_ed25519_sk_to_curve25519(byte[] x25519_sk, byte[] ed25519_sk)
47−
48− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
49− extern int private crypto_box_keypair(byte[] pk, byte[] sk)
50−
51− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
52− extern int private crypto_scalarmult(byte[] q, byte[] n, byte[] p)
53−
54− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
55− extern int private crypto_aead_xchacha20poly1305_ietf_encrypt(byte[] c, nativeint& clen, byte[] m, uint64 mlen, byte[] ad, uint64 adlen, nativeint nsec, byte[] npub, byte[] k)
56−
57− [<DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)>]
58− extern int private crypto_aead_xchacha20poly1305_ietf_decrypt(byte[] m, nativeint& mlen, nativeint nsec, byte[] c, uint64 clen, byte[] ad, uint64 adlen, byte[] npub, byte[] k)
59−
60− do
61− if sodium_init() < 0 then
62− failwith "sodium_init() failed"
63−
64− let private getRandomBytes n =
65− let buf = Array.zeroCreate n
66− randombytes_buf(buf, unativeint n)
67− buf
68−
69− let private generateSignKeyPair () =
70− let pk = Array.zeroCreate 32
71− let sk = Array.zeroCreate 64
72− if crypto_sign_keypair(pk, sk) <> 0 then failwith "crypto_sign_keypair failed"
73− pk, sk
74−
75− let private signDetached (message: byte[]) (sk: byte[]) =
76− let out = Array.zeroCreate 64
77− let mutable siglen = 0n
78− if crypto_sign_detached(out, &siglen, message, uint64 message.Length, sk) <> 0 then
79− failwith "crypto_sign_detached failed"
80− out
81−
82− let private verifyDetached (signature: byte[]) (message: byte[]) (pk: byte[]) =
83− crypto_sign_verify_detached(signature, message, uint64 message.Length, pk) = 0
84−
85− let private ed25519PkToCurve25519 (ed: byte[]) =
86− let x = Array.zeroCreate 32
87− if crypto_sign_ed25519_pk_to_curve25519(x, ed) <> 0 then failwith "pk_to_curve25519 failed"
88− x
89−
90− let private ed25519SkToCurve25519 (ed: byte[]) =
91− let x = Array.zeroCreate 32
92− if crypto_sign_ed25519_sk_to_curve25519(x, ed) <> 0 then failwith "sk_to_curve25519 failed"
93− x
94−
95− let private generateBoxKeyPair () =
96− let pk = Array.zeroCreate 32
97− let sk = Array.zeroCreate 32
98− if crypto_box_keypair(pk, sk) <> 0 then failwith "crypto_box_keypair failed"
99− pk, sk
100−
101− let private scalarMult (n: byte[]) (p: byte[]) =
102− let q = Array.zeroCreate 32
103− if crypto_scalarmult(q, n, p) <> 0 then failwith "crypto_scalarmult failed"
104− q
105−
106− let private aeadEncrypt (pt: byte[]) (nonce: byte[]) (key: byte[]) =
107− let ct = Array.zeroCreate (pt.Length + 16)
108− let mutable ctlen = 0n
109− if crypto_aead_xchacha20poly1305_ietf_encrypt(ct, &ctlen, pt, uint64 pt.Length, null, 0UL, 0n, nonce, key) <> 0 then
110− failwith "aead encrypt failed"
111− ct.[.. int ctlen - 1]
112−
113− let private aeadDecrypt (ct: byte[]) (nonce: byte[]) (key: byte[]) =
114− let pt = Array.zeroCreate ct.Length
115− let mutable ptlen = 0n
116− if crypto_aead_xchacha20poly1305_ietf_decrypt(pt, &ptlen, 0n, ct, uint64 ct.Length, null, 0UL, nonce, key) <> 0 then
117− failwith "aead decrypt failed"
118− pt.[.. int ptlen - 1]
119−
120−#else
121−
122− let private getRandomBytes n =
123− Sodium.SodiumCore.GetRandomBytes(n)
124−
125− let private generateSignKeyPair () =
126− let kp = Sodium.PublicKeyAuth.GenerateKeyPair()
127− kp.PublicKey, kp.PrivateKey
128−
129− let private signDetached (message: byte[]) (sk: byte[]) =
130− Sodium.PublicKeyAuth.SignDetached(message, sk)
131−
132− let private verifyDetached (signature: byte[]) (message: byte[]) (pk: byte[]) =
133− Sodium.PublicKeyAuth.VerifyDetached(signature, message, pk)
134−
135− let private ed25519PkToCurve25519 (pk: byte[]) =
136− Sodium.PublicKeyAuth.ConvertEd25519PublicKeyToCurve25519PublicKey(pk)
137−
138− let private ed25519SkToCurve25519 (sk: byte[]) =
139− Sodium.PublicKeyAuth.ConvertEd25519SecretKeyToCurve25519SecretKey(sk)
140−
141− let private generateBoxKeyPair () =
142− let kp = Sodium.PublicKeyBox.GenerateKeyPair()
143− kp.PublicKey, kp.PrivateKey
144−
145− let private scalarMult (n: byte[]) (p: byte[]) =
146− Sodium.ScalarMult.Mult(n, p)
147−
148− let private aeadEncrypt (pt: byte[]) (nonce: byte[]) (key: byte[]) =
149− Sodium.SecretAeadXChaCha20Poly1305.Encrypt(pt, nonce, key)
150−
151− let private aeadDecrypt (ct: byte[]) (nonce: byte[]) (key: byte[]) =
152− Sodium.SecretAeadXChaCha20Poly1305.Decrypt(ct, nonce, key)
153−
154−#endif
155−
156− // ── Shared algorithm (platform-independent) ──
157−
158− let private log msg = eprintfn $"[skrepka] {msg}"
159−
160− let identityFromPrivKey (privKey: byte[]) : Identity option =
161− if privKey.Length <> 64 then
162− None
163− else
164− Some
165− { PrivKey = privKey
166− PubKeyHex = toHex privKey.[32..63] }
167−
168− let generateIdentity () : Identity =
169− let _, sk = generateSignKeyPair ()
170− sk |> identityFromPrivKey |> Option.get
171−
172− let signChallenge (privKey: byte[]) (challenge: string) : string =
173− let message = Encoding.UTF8.GetBytes(challenge)
174− signDetached message privKey |> toHex
175−
176− let private deriveKey ephPub recipientX25519Pub rawSecret =
177− let salt = Array.append ephPub recipientX25519Pub
178− let info = Encoding.UTF8.GetBytes(Constants.hkdfInfo)
179− HKDF.DeriveKey(HashAlgorithmName.SHA256, rawSecret, 32, salt, info)
180−
181− // Blob layout: [ephPub(32) | nonce(24) | ciphertext]
182− // encrypted payload: [senderPub(32) | signature(64) | compressed]
183− // signature signs (recipientPub ++ compressed) — prevents forwarding
184− let private ephPubLen = 32
185− let private nonceLen = 24
186− let private pubLen = 32
187− let private sigLen = 64
188− let private headerLen = ephPubLen + nonceLen
189− let private minBlobLen = headerLen + pubLen + sigLen + 16 // inner fields + AEAD tag
190−
191− let private deriveSymKey recipientPrivKey ephPub =
192− let recipientX25519Priv = ed25519SkToCurve25519 recipientPrivKey
193− let rawSecret = scalarMult recipientX25519Priv ephPub
194− let recipientEd25519Pub = recipientPrivKey.[32..63]
195− let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
196− deriveKey ephPub recipientX25519Pub rawSecret
197−
198− let encrypt (senderPrivKey: byte[]) (recipientEd25519Pub: byte[]) (plaintext: string) : byte[] option =
199− if senderPrivKey.Length <> 64 || recipientEd25519Pub.Length <> 32 then
200− None
201− else
202− try
203− let senderPubKey = senderPrivKey.[32..63]
204− let ephPub, ephPriv = generateBoxKeyPair ()
205− let recipientX25519Pub = ed25519PkToCurve25519 recipientEd25519Pub
206− let rawSecret = scalarMult ephPriv recipientX25519Pub
207− let key = deriveKey ephPub recipientX25519Pub rawSecret
208− let nonce = getRandomBytes 24
209− let ptBytes = Encoding.UTF8.GetBytes(plaintext)
210− let compressed = LZ4Pickler.Pickle(ptBytes)
211− let signedData = Array.append recipientEd25519Pub compressed
212− let signature = signDetached signedData senderPrivKey
213− let inner = Array.concat [| senderPubKey; signature; compressed |]
214− let ciphertext = aeadEncrypt inner nonce key
215−
216− Some(Array.concat [| ephPub; nonce; ciphertext |])
217− with ex ->
218− log $"encrypt error: {ex.Message}"
219− None
220−
221− let decrypt (recipientPrivKey: byte[]) (blob: byte[]) : (string * string) option =
222− if recipientPrivKey.Length <> 64 || blob.Length < minBlobLen then
223− None
224− else
225− let ephPub = blob.[.. ephPubLen - 1]
226− let nonce = blob.[ephPubLen .. headerLen - 1]
227− let ciphertext = blob.[headerLen ..]
228− let key = deriveSymKey recipientPrivKey ephPub
229− let decrypted = aeadDecrypt ciphertext nonce key
230−
231− if decrypted.Length < pubLen + sigLen then
232− None
233− else
234− let senderPub = decrypted.[.. pubLen - 1]
235− let signature = decrypted.[pubLen .. pubLen + sigLen - 1]
236− let compressed = decrypted.[pubLen + sigLen ..]
237−
238− let recipientPub = recipientPrivKey.[32..63]
239− let signedData = Array.append recipientPub compressed
240−
241− if verifyDetached signature signedData senderPub then
242− let plaintext = LZ4Pickler.Unpickle(compressed)
243− Some(Encoding.UTF8.GetString(plaintext), toHex senderPub)
244− else
245− None
client/Keyboard.fsdeleted−11
@@ -1,11 +0,0 @@
1−namespace Skrepka
2−
3−module Keyboard =
4− let private evt = Event<float>()
5− let heightChanged = evt.Publish
6− let triggerHeightChanged h = evt.Trigger h
7−
8−module SafeArea =
9− let private evt = Event<float * float>()
10− let insetsChanged = evt.Publish
11− let triggerInsetsChanged (top, bottom) = evt.Trigger(top, bottom)
client/Labels.fsdeleted−27
@@ -1,27 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Avalonia.Media.TextFormatting
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module Labels =
10− let private label size text =
11− TextBlock(text)
12− .fontFamily(FontFamily(Constants.fontFamily))
13− .fontWeight(FontWeight.Bold)
14− .fontSize(size)
15− .textWrapping (TextWrapping.Wrap)
16−
17− let h1 text = label 32. text
18− let h2 text = label 24. text
19− let h3 text = label 20. text
20− let h4 text = label 16. text
21− let h5 text = label 14. text
22−
23− let body text =
24− label 14. text |> _.fontWeight(FontWeight.Normal)
25−
26− let caption text =
27− label 12. text |> _.fontWeight(FontWeight.Normal)
client/Phonemic.fsdeleted−143
@@ -1,143 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−
5−module Phonemic =
6−
7− // Urbit @p syllable tables (256 prefixes, 256 suffixes)
8− // Each byte pair encodes as prefix[hi] + suffix[lo]
9−
10− let private prefixes =
11− [| "doz"; "mar"; "bin"; "wan"; "sam"; "lit"; "sig"; "hid"
12− "fid"; "lis"; "sog"; "dir"; "wac"; "sab"; "wis"; "sib"
13− "rig"; "sol"; "dop"; "mod"; "fog"; "lid"; "hop"; "dar"
14− "dor"; "lor"; "hod"; "fol"; "rin"; "tog"; "sil"; "mir"
15− "hol"; "pas"; "lac"; "rov"; "liv"; "dal"; "sat"; "lib"
16− "tab"; "han"; "tic"; "pid"; "tor"; "bol"; "fos"; "dot"
17− "los"; "dil"; "for"; "pil"; "ram"; "tir"; "win"; "tad"
18− "bic"; "dif"; "roc"; "wid"; "bis"; "das"; "mid"; "lop"
19− "ril"; "nar"; "dap"; "mol"; "san"; "loc"; "nov"; "sit"
20− "nid"; "tip"; "sic"; "rop"; "wit"; "nat"; "pan"; "min"
21− "rit"; "pod"; "mot"; "tam"; "tol"; "sav"; "pos"; "nap"
22− "nop"; "som"; "fin"; "fon"; "ban"; "mor"; "wor"; "sip"
23− "ron"; "nor"; "bot"; "wic"; "soc"; "wat"; "dol"; "mag"
24− "pic"; "dav"; "bid"; "bal"; "tim"; "tas"; "mal"; "lig"
25− "siv"; "tag"; "pad"; "sal"; "div"; "dac"; "tan"; "sid"
26− "fab"; "tar"; "mon"; "ran"; "nis"; "wol"; "mis"; "pal"
27− "las"; "dis"; "map"; "rab"; "tob"; "rol"; "lat"; "lon"
28− "nod"; "nav"; "fig"; "nom"; "nib"; "pag"; "sop"; "ral"
29− "bil"; "had"; "doc"; "rid"; "moc"; "pac"; "rav"; "rip"
30− "fal"; "tod"; "til"; "tin"; "hap"; "mic"; "fan"; "pat"
31− "tac"; "lab"; "mog"; "sim"; "son"; "pin"; "lom"; "ric"
32− "tap"; "fir"; "has"; "bos"; "bat"; "poc"; "hac"; "tid"
33− "hav"; "sap"; "lin"; "dib"; "hos"; "dab"; "bit"; "bar"
34− "rac"; "par"; "lod"; "dos"; "bor"; "toc"; "hil"; "mac"
35− "tom"; "dig"; "fil"; "fas"; "mit"; "hob"; "har"; "mig"
36− "hin"; "rad"; "mas"; "hal"; "rag"; "lag"; "fad"; "top"
37− "mop"; "hab"; "nil"; "nos"; "mil"; "fop"; "fam"; "dat"
38− "nol"; "din"; "hat"; "nac"; "ris"; "fot"; "rib"; "hoc"
39− "nim"; "lar"; "fit"; "wal"; "rap"; "sar"; "nal"; "mos"
40− "lan"; "don"; "dan"; "lad"; "dov"; "riv"; "bac"; "pol"
41− "lap"; "tal"; "pit"; "nam"; "bon"; "ros"; "ton"; "fod"
42− "pon"; "sov"; "noc"; "sor"; "lav"; "mat"; "mip"; "fip" |]
43−
44− let private suffixes =
45− [| "zod"; "nec"; "bud"; "wes"; "sev"; "per"; "sut"; "let"
46− "ful"; "pen"; "syt"; "dur"; "wep"; "ser"; "wyl"; "sun"
47− "ryp"; "syx"; "dyr"; "nup"; "heb"; "peg"; "lup"; "dep"
48− "dys"; "put"; "lug"; "hec"; "ryt"; "tyv"; "syd"; "nex"
49− "lun"; "mep"; "lut"; "sep"; "pes"; "del"; "sul"; "ped"
50− "tem"; "led"; "tul"; "met"; "wen"; "byn"; "hex"; "feb"
51− "pyl"; "dul"; "het"; "mev"; "rut"; "tyl"; "wyd"; "tep"
52− "bes"; "dex"; "sef"; "wyc"; "bur"; "der"; "nep"; "pur"
53− "rys"; "reb"; "den"; "nut"; "sub"; "pet"; "rul"; "syn"
54− "reg"; "tyd"; "sup"; "sem"; "wyn"; "rec"; "meg"; "net"
55− "sec"; "mul"; "nym"; "tev"; "web"; "sum"; "mut"; "nyx"
56− "rex"; "teb"; "fus"; "hep"; "ben"; "mus"; "wyx"; "sym"
57− "sel"; "ruc"; "dec"; "wex"; "syr"; "wet"; "dyl"; "myn"
58− "mes"; "det"; "bet"; "bel"; "tux"; "tug"; "myr"; "pel"
59− "syp"; "ter"; "meb"; "set"; "dut"; "deg"; "tex"; "sur"
60− "fel"; "tud"; "nux"; "rux"; "ren"; "wyt"; "nub"; "med"
61− "lyt"; "dus"; "neb"; "rum"; "tyn"; "seg"; "lyx"; "pun"
62− "res"; "red"; "fun"; "rev"; "ref"; "mec"; "ted"; "rus"
63− "bex"; "leb"; "dux"; "ryn"; "num"; "pyx"; "ryg"; "ryx"
64− "fep"; "tyr"; "tus"; "tyc"; "leg"; "nem"; "fer"; "mer"
65− "ten"; "lus"; "nus"; "syl"; "tec"; "mex"; "pub"; "rym"
66− "tuc"; "fyl"; "lep"; "deb"; "ber"; "mug"; "hut"; "tun"
67− "byl"; "sud"; "pem"; "dev"; "lur"; "def"; "bus"; "bep"
68− "run"; "mel"; "pex"; "dyt"; "byt"; "typ"; "lev"; "myl"
69− "wed"; "duc"; "fur"; "fex"; "nul"; "luc"; "len"; "ner"
70− "lex"; "rup"; "ned"; "lec"; "ryd"; "lyd"; "fen"; "wel"
71− "nyd"; "hus"; "rel"; "rud"; "nes"; "hes"; "fet"; "des"
72− "ret"; "dun"; "ler"; "nyr"; "seb"; "hul"; "ryl"; "lud"
73− "rem"; "lys"; "fyn"; "wer"; "ryc"; "sug"; "nys"; "nyl"
74− "lyn"; "dyn"; "dem"; "lux"; "fed"; "sed"; "bec"; "mun"
75− "lyr"; "tes"; "mud"; "nyt"; "byr"; "sen"; "weg"; "fyr"
76− "mur"; "tel"; "rep"; "teg"; "pec"; "nel"; "nev"; "fes" |]
77−
78− let private prefixMap =
79− prefixes |> Array.mapi (fun i s -> s, byte i) |> Map.ofArray
80−
81− let private suffixMap =
82− suffixes |> Array.mapi (fun i s -> s, byte i) |> Map.ofArray
83−
84− let toOb (bytes: byte[]) : string =
85− bytes
86− |> Array.chunkBySize 2
87− |> Array.map (fun pair -> $"{prefixes.[int pair.[0]]}{suffixes.[int pair.[1]]}")
88− |> String.concat "-"
89−
90− type ObValidation =
91− | Empty
92− | Partial of validPairs: int
93− | InvalidSyllable of syllable: string
94− | Valid
95−
96− let validateOb (s: string) : ObValidation =
97− let s = s.Trim()
98−
99− if s = "" then
100− Empty
101− else
102− let parts = s.Split('-', StringSplitOptions.RemoveEmptyEntries)
103−
104− // Don't flag the trailing part if still being typed
105− let completeParts =
106− if parts.Length > 0 && parts.[parts.Length - 1].Length < 6 then
107− parts.[.. parts.Length - 2]
108− else
109− parts
110−
111− let firstInvalid =
112− completeParts
113− |> Array.tryFind (fun sp ->
114− sp.Length <> 6
115− || not (Map.containsKey sp.[0..2] prefixMap)
116− || not (Map.containsKey sp.[3..5] suffixMap))
117−
118− match firstInvalid with
119− | Some s -> InvalidSyllable s
120− | None when completeParts.Length = 16 -> Valid
121− | None -> Partial completeParts.Length
122−
123− let fromOb (s: string) : byte[] option =
124− let pairs = s.Split('-', StringSplitOptions.RemoveEmptyEntries)
125−
126− if pairs.Length = 0 then
127− None
128− else
129−
130− let decoded =
131− pairs
132− |> Array.choose (fun sp ->
133− if sp.Length = 6 then
134− match Map.tryFind sp.[0..2] prefixMap, Map.tryFind sp.[3..5] suffixMap with
135− | Some hi, Some lo -> Some(hi, lo)
136− | _ -> None
137− else
138− None)
139−
140− if decoded.Length = pairs.Length then
141− decoded |> Array.collect (fun (hi, lo) -> [| hi; lo |]) |> Some
142− else
143− None
client/Platform/Desktop/AppIcon.icnsdeleted

Binary file not shown.

client/Platform/Desktop/Info.plistdeleted−24
@@ -1,24 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3−<plist version="1.0">
4−<dict>
5− <key>CFBundleName</key>
6− <string>Skrepka</string>
7− <key>CFBundleDisplayName</key>
8− <string>Skrepka</string>
9− <key>CFBundleIdentifier</key>
10− <string>com.skrepka.client</string>
11− <key>CFBundleVersion</key>
12− <string>1.0</string>
13− <key>CFBundleShortVersionString</key>
14− <string>1.0</string>
15− <key>CFBundlePackageType</key>
16− <string>APPL</string>
17− <key>CFBundleExecutable</key>
18− <string>Skrepka</string>
19− <key>CFBundleIconFile</key>
20− <string>AppIcon</string>
21− <key>NSHighResolutionCapable</key>
22− <true/>
23−</dict>
24−</plist>
client/Platform/Desktop/Program.fsdeleted−18
@@ -1,18 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open Avalonia
5−open Fabulous.Avalonia
6−
7−module Program =
8−
9− [<CompiledName "BuildAvaloniaApp">]
10− let buildAvaloniaApp () =
11− App
12− .create()
13− .UsePlatformDetect()
14− .LogToTrace(areas = Array.empty)
15−
16− [<EntryPoint; STAThread>]
17− let main argv =
18− buildAvaloniaApp().StartWithClassicDesktopLifetime(argv)
client/Platform/iOS/AppDelegate.fsdeleted−61
@@ -1,61 +0,0 @@
1−namespace Skrepka.iOS
2−
3−open System
4−open Foundation
5−open UIKit
6−open Avalonia
7−open Avalonia.iOS
8−open Fabulous.Avalonia
9−open Skrepka
10−
11−[<Register(nameof AppDelegate)>]
12−type AppDelegate() =
13− inherit AvaloniaAppDelegate<FabApplication>()
14−
15− let readSafeArea () =
16− let w = UIApplication.SharedApplication.KeyWindow
17− if not (isNull w) then
18− SafeArea.triggerInsetsChanged(float w.SafeAreaInsets.Top, float w.SafeAreaInsets.Bottom)
19−
20− override this.CreateAppBuilder() =
21− NSTimer.CreateScheduledTimer(0.1, false, fun _ ->
22− if this.Window <> null then
23− this.Window.BackgroundColor <- UIColor.White
24− readSafeArea())
25− |> ignore
26−
27− UIDevice.CurrentDevice.BeginGeneratingDeviceOrientationNotifications()
28− NSNotificationCenter.DefaultCenter.AddObserver(
29− UIDevice.OrientationDidChangeNotification,
30− Action<NSNotification>(fun _ ->
31− NSTimer.CreateScheduledTimer(0.1, false, fun _ -> readSafeArea()) |> ignore))
32− |> ignore
33−
34− NSNotificationCenter.DefaultCenter.AddObserver(
35− UIKeyboard.WillShowNotification,
36− Action<NSNotification>(fun n ->
37− let frame =
38− (n.UserInfo.ObjectForKey(UIKeyboard.FrameEndUserInfoKey) :?> NSValue)
39− .CGRectValue
40−
41− let h = float frame.Height
42−
43− let safeBottom =
44− let w = UIApplication.SharedApplication.KeyWindow
45− if isNull w then 0. else float w.SafeAreaInsets.Bottom
46−
47− Keyboard.triggerHeightChanged (max 0. (h - safeBottom))))
48− |> ignore
49−
50− NSNotificationCenter.DefaultCenter.AddObserver(
51− UIKeyboard.WillHideNotification,
52− Action<NSNotification>(fun _ -> Keyboard.triggerHeightChanged 0.))
53− |> ignore
54−
55− App.create().UseiOS()
56−
57−module Main =
58− [<EntryPoint>]
59− let main (args: string array) =
60− UIApplication.Main(args, null, typeof<AppDelegate>)
61− 0
client/Platform/iOS/Entitlements.plistdeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3−<plist version="1.0">
4−<dict>
5−</dict>
6−</plist>
client/Platform/iOS/Info.plistdeleted−43
@@ -1,43 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3−<plist version="1.0">
4−<dict>
5− <key>CFBundleDisplayName</key>
6− <string>Skrepka</string>
7− <key>CFBundleIdentifier</key>
8− <string>com.skrepka.client</string>
9− <key>CFBundleShortVersionString</key>
10− <string>1.0</string>
11− <key>CFBundleVersion</key>
12− <string>1.0</string>
13− <key>LSRequiresIPhoneOS</key>
14− <true/>
15− <key>MinimumOSVersion</key>
16− <string>15.0</string>
17− <key>UIDeviceFamily</key>
18− <array>
19− <integer>1</integer>
20− <integer>2</integer>
21− </array>
22− <key>UILaunchScreen</key>
23− <dict/>
24− <key>UIRequiredDeviceCapabilities</key>
25− <array>
26− <string>armv7</string>
27− </array>
28− <key>UISupportedInterfaceOrientations</key>
29− <array>
30− <string>UIInterfaceOrientationPortrait</string>
31− <string>UIInterfaceOrientationLandscapeLeft</string>
32− <string>UIInterfaceOrientationLandscapeRight</string>
33− </array>
34− <key>XSAppIconAssets</key>
35− <string>Assets.xcassets/AppIcon.appiconset</string>
36− <key>NSPhotoLibraryUsageDescription</key>
37− <string>Skrepka needs access to your photo library to set a profile photo.</string>
38− <key>UIStatusBarHidden</key>
39− <true/>
40− <key>UIViewControllerBasedStatusBarAppearance</key>
41− <false/>
42−</dict>
43−</plist>
client/Platform/iOS/PhotoPicker.fsdeleted−50
@@ -1,50 +0,0 @@
1−namespace Skrepka.iOS
2−
3−open System
4−open System.Threading.Tasks
5−open Foundation
6−open UIKit
7−open PhotosUI
8−
9−type private PickerDelegate(tcs: TaskCompletionSource<byte[] option>) =
10− inherit PHPickerViewControllerDelegate()
11−
12− override _.DidFinishPicking(controller, results) =
13− controller.DismissViewController(true, null)
14−
15− if results.Length = 0 then
16− tcs.TrySetResult None |> ignore
17− else
18− results[0].ItemProvider.LoadDataRepresentation(
19− "public.image",
20− Action<NSData, NSError>(fun data error ->
21− if isNull error && not (isNull data) then
22− tcs.TrySetResult(Some(data.ToArray())) |> ignore
23− else
24− tcs.TrySetResult None |> ignore))
25− |> ignore
26−
27−module PhotoPicker =
28− let mutable private prevent_gc: obj = null
29−
30− let pick () : Task<byte[] option> =
31− let tcs = TaskCompletionSource<byte[] option>()
32−
33− Avalonia.Threading.Dispatcher.UIThread.Post(fun () ->
34− let config = new PHPickerConfiguration()
35− config.Filter <- PHPickerFilter.ImagesFilter
36− config.SelectionLimit <- 1n
37−
38− let picker = new PHPickerViewController(config)
39− let d = new PickerDelegate(tcs)
40− prevent_gc <- box (picker, d)
41− tcs.Task.ContinueWith(fun (_: Task<byte[] option>) -> prevent_gc <- null) |> ignore
42− picker.Delegate <- d
43−
44− let window = UIApplication.SharedApplication.KeyWindow
45− if not (isNull window) && not (isNull window.RootViewController) then
46− window.RootViewController.PresentViewController(picker, true, null)
47− else
48− tcs.TrySetResult None |> ignore)
49−
50− tcs.Task
client/Platform/iOS/QRScanner.fsdeleted−113
@@ -1,113 +0,0 @@
1−namespace Skrepka.iOS
2−
3−open System
4−open System.Threading.Tasks
5−open Foundation
6−open UIKit
7−open AVFoundation
8−open CoreFoundation
9−
10−type private ScanDelegate(tcs: TaskCompletionSource<string option>, session: AVCaptureSession) =
11− inherit AVCaptureMetadataOutputObjectsDelegate()
12−
13− let mutable handled = false
14−
15− override _.DidOutputMetadataObjects(output, metadataObjects, connection) =
16− if not handled && metadataObjects.Length > 0 then
17− match metadataObjects.[0] with
18− | :? AVMetadataMachineReadableCodeObject as code when code.Type = AVMetadataObjectType.QRCode ->
19− handled <- true
20− session.StopRunning()
21−
22− DispatchQueue.MainQueue.DispatchAsync(fun () ->
23− let window = UIApplication.SharedApplication.KeyWindow
24−
25− if not (isNull window) && not (isNull window.RootViewController) then
26− window.RootViewController.DismissViewController(true, fun () ->
27− tcs.TrySetResult(Some code.StringValue) |> ignore)
28− else
29− tcs.TrySetResult(Some code.StringValue) |> ignore)
30− | _ -> ()
31−
32−module QRScanner =
33− let mutable private prevent_gc: obj = null
34−
35− let private presentScanner (tcs: TaskCompletionSource<string option>) =
36− let session = new AVCaptureSession()
37−
38− match AVCaptureDevice.GetDefaultDevice(AVMediaTypes.Video) with
39− | null -> tcs.TrySetResult None |> ignore
40− | device ->
41− let mutable error: NSError = null
42− let input = AVCaptureDeviceInput.FromDevice(device, &error)
43−
44− if isNull input then
45− tcs.TrySetResult None |> ignore
46− else
47− session.AddInput(input)
48−
49− let output = new AVCaptureMetadataOutput()
50− session.AddOutput(output)
51−
52− let del = new ScanDelegate(tcs, session)
53− output.SetDelegate(del, DispatchQueue.MainQueue)
54− output.MetadataObjectTypes <- AVMetadataObjectType.QRCode
55−
56− let preview = AVCaptureVideoPreviewLayer.FromSession(session)
57− preview.VideoGravity <- AVLayerVideoGravity.ResizeAspectFill
58− preview.Frame <- UIScreen.MainScreen.Bounds
59−
60− let vc = new UIViewController()
61− vc.View.BackgroundColor <- UIColor.Black
62− vc.View.Layer.AddSublayer(preview)
63−
64− let closeBtn = UIButton.FromType(UIButtonType.System)
65− closeBtn.SetTitle("Cancel", UIControlState.Normal)
66− closeBtn.SetTitleColor(UIColor.White, UIControlState.Normal)
67− closeBtn.Frame <- CoreGraphics.CGRect(20., 60., 100., 44.)
68−
69− closeBtn.TouchUpInside.Add(fun _ ->
70− session.StopRunning()
71−
72− vc.DismissViewController(true, fun () ->
73− tcs.TrySetResult None |> ignore))
74−
75− vc.View.AddSubview(closeBtn)
76−
77− prevent_gc <- box (session, del, preview, vc)
78−
79− tcs.Task.ContinueWith(fun (_: Task<string option>) -> prevent_gc <- null)
80− |> ignore
81−
82− session.StartRunning()
83−
84− let window = UIApplication.SharedApplication.KeyWindow
85−
86− if not (isNull window) && not (isNull window.RootViewController) then
87− vc.ModalPresentationStyle <- UIModalPresentationStyle.FullScreen
88− window.RootViewController.PresentViewController(vc, true, null)
89− else
90− session.StopRunning()
91− tcs.TrySetResult None |> ignore
92−
93− let scan () : Task<string option> =
94− let tcs = TaskCompletionSource<string option>()
95−
96− Avalonia.Threading.Dispatcher.UIThread.Post(fun () ->
97− let status = AVCaptureDevice.GetAuthorizationStatus(AVAuthorizationMediaType.Video)
98−
99− match status with
100− | AVAuthorizationStatus.Denied
101− | AVAuthorizationStatus.Restricted -> tcs.TrySetResult None |> ignore
102− | AVAuthorizationStatus.NotDetermined ->
103− AVCaptureDevice.RequestAccessForMediaType(
104− AVAuthorizationMediaType.Video,
105− fun granted ->
106− if granted then
107− DispatchQueue.MainQueue.DispatchAsync(fun () -> presentScanner tcs)
108− else
109− tcs.TrySetResult None |> ignore
110− )
111− | _ -> presentScanner tcs)
112−
113− tcs.Task
client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.jsondeleted−23
@@ -1,23 +0,0 @@
1−{
2− "images": [
3− { "size": "20x20", "idiom": "iphone", "filename": "icon-40.png", "scale": "2x" },
4− { "size": "20x20", "idiom": "iphone", "filename": "icon-60.png", "scale": "3x" },
5− { "size": "29x29", "idiom": "iphone", "filename": "icon-58.png", "scale": "2x" },
6− { "size": "29x29", "idiom": "iphone", "filename": "icon-87.png", "scale": "3x" },
7− { "size": "40x40", "idiom": "iphone", "filename": "icon-80.png", "scale": "2x" },
8− { "size": "40x40", "idiom": "iphone", "filename": "icon-120.png", "scale": "3x" },
9− { "size": "60x60", "idiom": "iphone", "filename": "icon-120.png", "scale": "2x" },
10− { "size": "60x60", "idiom": "iphone", "filename": "icon-180.png", "scale": "3x" },
11− { "size": "20x20", "idiom": "ipad", "filename": "icon-20.png", "scale": "1x" },
12− { "size": "20x20", "idiom": "ipad", "filename": "icon-40.png", "scale": "2x" },
13− { "size": "29x29", "idiom": "ipad", "filename": "icon-29.png", "scale": "1x" },
14− { "size": "29x29", "idiom": "ipad", "filename": "icon-58.png", "scale": "2x" },
15− { "size": "40x40", "idiom": "ipad", "filename": "icon-40.png", "scale": "1x" },
16− { "size": "40x40", "idiom": "ipad", "filename": "icon-80.png", "scale": "2x" },
17− { "size": "76x76", "idiom": "ipad", "filename": "icon-76.png", "scale": "1x" },
18− { "size": "76x76", "idiom": "ipad", "filename": "icon-152.png", "scale": "2x" },
19− { "size": "83.5x83.5", "idiom": "ipad", "filename": "icon-167.png", "scale": "2x" },
20− { "size": "1024x1024", "idiom": "ios-marketing", "filename": "icon-1024.png", "scale": "1x" }
21− ],
22− "info": { "version": 1, "author": "xcode" }
23−}
client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-120.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-152.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-167.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-180.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-20.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-29.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-58.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-60.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-80.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/AppIcon.appiconset/icon-87.pngdeleted

Binary file not shown.

client/Platform/iOS/Resources/Assets.xcassets/Contents.jsondeleted−3
@@ -1,3 +0,0 @@
1−{
2− "info": { "version": 1, "author": "xcode" }
3−}
client/Platform/iOS/Resources/LaunchScreen.storyboarddeleted−25
@@ -1,25 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2−<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
3− <dependencies>
4− <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
5− <capability name="Safe area layout guides" minToolsVersion="9.0"/>
6− <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
7− </dependencies>
8− <scenes>
9− <!--View Controller-->
10− <scene sceneID="EHf-IW-A2E">
11− <objects>
12− <viewController id="01J-lp-oVM" sceneMemberID="viewController">
13− <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
14− <rect key="frame" x="0.0" y="0.0" width="375" height="812"/>
15− <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
16− <color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
17− <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
18− </view>
19− </viewController>
20− <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
21− </objects>
22− <point key="canvasLocation" x="53" y="375"/>
23− </scene>
24− </scenes>
25−</document>
client/Protocol.fsdeleted−110
@@ -1,110 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.Text.Json
5−
6−module Protocol =
7−
8− open Store
9− open Crypto
10− open ApiClient
11− open AppTypes
12−
13− let private log msg = eprintfn $"[skrepka] {msg}"
14−
15− let serializeEnvelope (ts: int64) =
16− function
17− | Envelope.TextMessage(id, body) ->
18− JsonSerializer.Serialize(
19− {| ``type`` = "text"
20− id = id
21− body = body
22− ts = ts |}
23− )
24− | Envelope.DeliveryAck ackIds ->
25− JsonSerializer.Serialize(
26− {| ``type`` = "delivery.ack"
27− ack_ids = ackIds
28− ts = ts |}
29− )
30− | Envelope.ProfileMessage profile ->
31− JsonSerializer.Serialize(
32− {| ``type`` = "profile"
33− display_name = profile.DisplayName
34− bio = profile.Bio
35− photo = profile.Photo
36− ts = ts |}
37− )
38−
39− let private tryGetJsonString (el: JsonElement) (name: string) =
40− match el.TryGetProperty(name) with
41− | true, v when v.ValueKind <> JsonValueKind.Null -> Some(v.GetString())
42− | _ -> None
43−
44− let private tryGetJsonBytes (el: JsonElement) (name: string) =
45− match el.TryGetProperty(name) with
46− | true, v when v.ValueKind <> JsonValueKind.Null -> Some(v.GetBytesFromBase64())
47− | _ -> None
48−
49− let private parseEnvelope (json: string) =
50− use doc = JsonDocument.Parse(json)
51− let root = doc.RootElement
52−
53− let ts =
54− match root.TryGetProperty("ts") with
55− | true, v when v.ValueKind = JsonValueKind.Number -> v.GetInt64()
56− | _ -> DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
57−
58− let envelope =
59− match tryGetJsonString root "type" with
60− | Some "text" ->
61− match tryGetJsonString root "id", tryGetJsonString root "body" with
62− | Some id, Some body -> Some(Envelope.TextMessage(id, body))
63− | _ -> None
64− | Some "delivery.ack" ->
65− match root.TryGetProperty("ack_ids") with
66− | true, v ->
67− let ackIds = v.EnumerateArray() |> Seq.map _.GetString() |> Seq.toList
68− Some(Envelope.DeliveryAck ackIds)
69− | _ -> None
70− | Some "profile" ->
71− let displayName = tryGetJsonString root "display_name" |> Option.defaultValue ""
72− let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
73− let photo = tryGetJsonBytes root "photo"
74−
75− Some(
76− Envelope.ProfileMessage
77− { DisplayName = displayName
78− Bio = bio
79− Photo = photo }
80− )
81− | _ -> None
82−
83− envelope |> Option.map (fun e -> ts, e)
84−
85− let decryptEvent (privKey: byte[]) (event: PollEvent) =
86− try
87− let blob = Crypto.fromHex event.EncryptedBlob
88−
89− match Crypto.decrypt privKey blob with
90− | Some(plaintext, senderHex) ->
91− match parseEnvelope plaintext with
92− | Some(ts, envelope) ->
93− Ok
94− { Sender = senderHex
95− Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(ts)
96− Envelope = envelope }
97− | None -> Error "unknown envelope type"
98− | None -> Error "decrypt failed"
99− with ex ->
100− Error $"parse: {ex.Message}"
101−
102− let sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
103− async {
104− let ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
105− let payload = serializeEnvelope ts envelope
106−
107− match Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex recipientHex) payload with
108− | Some blob -> do! sendMessage session.Url session.Token recipientHex (Crypto.toHex blob)
109− | None -> log $"encrypt failed for {recipientHex}"
110− }
client/Skrepka.app/Contents/Info.plistdeleted−24
@@ -1,24 +0,0 @@
1−<?xml version="1.0" encoding="UTF-8"?>
2−<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3−<plist version="1.0">
4−<dict>
5− <key>CFBundleName</key>
6− <string>Skrepka</string>
7− <key>CFBundleDisplayName</key>
8− <string>Skrepka</string>
9− <key>CFBundleIdentifier</key>
10− <string>com.skrepka.client</string>
11− <key>CFBundleVersion</key>
12− <string>1.0</string>
13− <key>CFBundleShortVersionString</key>
14− <string>1.0</string>
15− <key>CFBundlePackageType</key>
16− <string>APPL</string>
17− <key>CFBundleExecutable</key>
18− <string>Skrepka</string>
19− <key>CFBundleIconFile</key>
20− <string>AppIcon</string>
21− <key>NSHighResolutionCapable</key>
22− <true/>
23−</dict>
24−</plist>
client/Skrepka.app/Contents/MacOS/Skrepkadeleted−1
@@ -1 +0,0 @@
1−../../../Skrepka
client/Skrepka.app/Contents/Resources/AppIcon.icnsdeleted

Binary file not shown.

client/Skrepka.fsprojdeleted−138
@@ -1,138 +0,0 @@
1−<Project Sdk="Microsoft.NET.Sdk">
2−
3− <PropertyGroup>
4− <OutputType>Exe</OutputType>
5− <TargetFrameworks>net10.0</TargetFrameworks>
6− <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios</TargetFrameworks>
7− <RootNamespace>Skrepka</RootNamespace>
8− <DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
9− <NoWarn>MT7091;NETSDK1206</NoWarn>
10− </PropertyGroup>
11−
12− <PropertyGroup>
13− <AvaloniaPlatform>$([MSBuild]::GetTargetPlatformIdentifier($(TargetFramework)))</AvaloniaPlatform>
14− <AvaloniaPlatform Condition="'$(AvaloniaPlatform)' == ''">Desktop</AvaloniaPlatform>
15− <AvaloniaPlatform Condition="'$(AvaloniaPlatform)' == 'ios'">iOS</AvaloniaPlatform>
16−
17− <iOSPlatformFolder>Platform/iOS/</iOSPlatformFolder>
18− <DesktopPlatformFolder>Platform/Desktop/</DesktopPlatformFolder>
19− </PropertyGroup>
20−
21− <PropertyGroup Condition="'$(AvaloniaPlatform)' == 'iOS'">
22− <DefineConstants>$(DefineConstants);MOBILE</DefineConstants>
23− <SupportedOSPlatformVersion>15.0</SupportedOSPlatformVersion>
24− <RuntimeIdentifier>iossimulator-arm64</RuntimeIdentifier>
25− <ValidateXcodeVersion>false</ValidateXcodeVersion>
26− <UseInterpreter>true</UseInterpreter>
27− <UseNativeHttpHandler>true</UseNativeHttpHandler>
28− </PropertyGroup>
29−
30− <ItemGroup>
31− <Compile Include="Constants.fs" />
32− <Compile Include="Styles.fs" />
33− <Compile Include="Crypto.fs" />
34− <Compile Include="ApiClient.fs" />
35− <Compile Include="Phonemic.fs" />
36− <Compile Include="Store.fs" />
37− <Compile Include="TextFields.fs" />
38− <Compile Include="Labels.fs" />
39− <Compile Include="Buttons.fs" />
40− <Compile Include="$(iOSPlatformFolder)PhotoPicker.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
41− <Compile Include="$(iOSPlatformFolder)QRScanner.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
42− <Compile Include="Keyboard.fs" />
43− <Compile Include="AppTypes.fs" />
44− <Compile Include="Protocol.fs" />
45− <Compile Include="ViewSetup.fs" />
46− <Compile Include="ViewSettings.fs" />
47− <Compile Include="ViewConversations.fs" />
48− <Compile Include="ViewChat.fs" />
49− <Compile Include="ViewAddContact.fs" />
50− <Compile Include="ViewEditProfile.fs" />
51− <Compile Include="App.fs" />
52− </ItemGroup>
53−
54− <ItemGroup>
55− <AvaloniaResource Include="Assets\Fonts\*" />
56− <AvaloniaResource Include="Assets\Images\*" />
57− </ItemGroup>
58−
59− <ItemGroup Condition="$(AvaloniaPlatform) == 'Desktop'">
60− <Compile Include="$(DesktopPlatformFolder)Program.fs" />
61− <None Include="$(DesktopPlatformFolder)AppIcon.icns" />
62− <None Include="$(DesktopPlatformFolder)Info.plist" />
63− <PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
64− </ItemGroup>
65−
66− <Target Name="CreateMacAppBundle" AfterTargets="Build" Condition="$(AvaloniaPlatform) == 'Desktop' And $([MSBuild]::IsOSPlatform('osx'))">
67− <PropertyGroup>
68− <_AppBundle>$(TargetDir)Skrepka.app</_AppBundle>
69− </PropertyGroup>
70− <MakeDir Directories="$(_AppBundle)/Contents/MacOS" />
71− <MakeDir Directories="$(_AppBundle)/Contents/Resources" />
72− <Copy SourceFiles="$(DesktopPlatformFolder)Info.plist" DestinationFolder="$(_AppBundle)/Contents" />
73− <Copy SourceFiles="$(DesktopPlatformFolder)AppIcon.icns" DestinationFiles="$(_AppBundle)/Contents/Resources/AppIcon.icns" />
74− <Exec Command="ln -sf '../../../$(AssemblyName)' '$(_AppBundle)/Contents/MacOS/Skrepka'" />
75− </Target>
76−
77− <ItemGroup Condition="$(AvaloniaPlatform) == 'iOS'">
78− <None Include="$(iOSPlatformFolder)Entitlements.plist" />
79− <None Include="$(iOSPlatformFolder)Info.plist" LogicalName="Info.plist" />
80− <ImageAsset Include="$(iOSPlatformFolder)Resources\Assets.xcassets\Contents.json" />
81− <ImageAsset Include="$(iOSPlatformFolder)Resources\Assets.xcassets\AppIcon.appiconset\Contents.json" />
82− <ImageAsset Include="$(iOSPlatformFolder)Resources\Assets.xcassets\AppIcon.appiconset\*.png" />
83− <Compile Include="$(iOSPlatformFolder)AppDelegate.fs" />
84− <PackageReference Include="Avalonia.iOS" Version="11.3.0" />
85− </ItemGroup>
86−
87− <Target Name="FixBundlePlist" AfterTargets="Build" Condition="$(AvaloniaPlatform) == 'iOS'">
88− <PropertyGroup>
89− <_Plist>$(AppBundleDir)/Info.plist</_Plist>
90− <_PB>/usr/libexec/PlistBuddy</_PB>
91− </PropertyGroup>
92− <!-- Launch screen (full-screen, no letterboxing) -->
93− <Exec Command="$(_PB) -c 'Delete :UILaunchStoryboardName' '$(_Plist)'" ContinueOnError="true" />
94− <Exec Command="$(_PB) -c 'Add :UILaunchScreen dict' '$(_Plist)'" ContinueOnError="true" />
95− <!-- Privacy descriptions -->
96− <Exec Command="$(_PB) -c 'Add :NSCameraUsageDescription string Skrepka needs access to your camera to scan QR codes.' '$(_Plist)'" ContinueOnError="true" />
97− <Exec Command="$(_PB) -c 'Add :NSPhotoLibraryUsageDescription string Skrepka needs access to your photo library to set a profile photo.' '$(_Plist)'" ContinueOnError="true" />
98− <!-- App icon (fallback when actool produces empty output) -->
99− <Exec Command="$(_PB) -c 'Add :CFBundleIcons dict' '$(_Plist)'" ContinueOnError="true" />
100− <Exec Command="$(_PB) -c 'Add :CFBundleIcons:CFBundlePrimaryIcon dict' '$(_Plist)'" ContinueOnError="true" />
101− <Exec Command="$(_PB) -c 'Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles array' '$(_Plist)'" ContinueOnError="true" />
102− <Exec Command="$(_PB) -c 'Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconFiles:0 string AppIcon60x60' '$(_Plist)'" ContinueOnError="true" />
103− <Exec Command="$(_PB) -c 'Add :CFBundleIcons:CFBundlePrimaryIcon:CFBundleIconName string AppIcon' '$(_Plist)'" ContinueOnError="true" />
104− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad dict' '$(_Plist)'" ContinueOnError="true" />
105− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon dict' '$(_Plist)'" ContinueOnError="true" />
106− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles array' '$(_Plist)'" ContinueOnError="true" />
107− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles:0 string AppIcon60x60' '$(_Plist)'" ContinueOnError="true" />
108− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconFiles:1 string AppIcon76x76' '$(_Plist)'" ContinueOnError="true" />
109− <Exec Command="$(_PB) -c 'Add :CFBundleIcons~ipad:CFBundlePrimaryIcon:CFBundleIconName string AppIcon' '$(_Plist)'" ContinueOnError="true" />
110− <!-- Copy icon PNGs to bundle (fallback when actool doesn't produce Assets.car) -->
111− <Copy SourceFiles="$(iOSPlatformFolder)Resources/Assets.xcassets/AppIcon.appiconset/icon-120.png" DestinationFiles="$(AppBundleDir)/AppIcon60x60@2x.png" SkipUnchangedFiles="true" />
112− <Copy SourceFiles="$(iOSPlatformFolder)Resources/Assets.xcassets/AppIcon.appiconset/icon-180.png" DestinationFiles="$(AppBundleDir)/AppIcon60x60@3x.png" SkipUnchangedFiles="true" />
113− <Copy SourceFiles="$(iOSPlatformFolder)Resources/Assets.xcassets/AppIcon.appiconset/icon-152.png" DestinationFiles="$(AppBundleDir)/AppIcon76x76@2x~ipad.png" SkipUnchangedFiles="true" />
114− <Copy SourceFiles="$(iOSPlatformFolder)Resources/Assets.xcassets/AppIcon.appiconset/icon-167.png" DestinationFiles="$(AppBundleDir)/AppIcon83.5x83.5@2x~ipad.png" SkipUnchangedFiles="true" />
115− </Target>
116−
117− <ItemGroup Condition="$(AvaloniaPlatform) == 'iOS' And $(RuntimeIdentifier.StartsWith('iossimulator'))">
118− <NativeReference Include="native/iossimulator-arm64/libsodium.a">
119− <Kind>Static</Kind>
120− <ForceLoad>true</ForceLoad>
121− </NativeReference>
122− </ItemGroup>
123−
124− <ItemGroup>
125− <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
126− <PackageReference Include="FSharp.Core" Version="11.0.100-preview3.26159.112" />
127− <PackageReference Include="Fabulous" Version="3.0.0-pre22" />
128− <PackageReference Include="Fabulous.Avalonia" Version="3.0.0-pre13" />
129− <PackageReference Include="Fabulous.Avalonia.ItemsRepeater" Version="3.0.0-pre13" />
130− <PackageReference Include="Sodium.Core" Version="1.4.0" />
131− <PackageReference Include="Donald" Version="10.0.2" />
132− <PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.2.26159.112" />
133− <PackageReference Include="K4os.Compression.LZ4" Version="1.3.8" />
134− <PackageReference Include="QRCoder" Version="1.6.0" />
135− <PackageReference Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.22" Condition="$(AvaloniaPlatform) == 'Desktop'" />
136− </ItemGroup>
137−
138−</Project>
client/Skrepka.slndeleted−28
@@ -1,28 +0,0 @@
1−
2−Microsoft Visual Studio Solution File, Format Version 12.00
3−# Visual Studio Version 17
4−VisualStudioVersion = 17.0.31611.283
5−MinimumVisualStudioVersion = 10.0.40219.1
6−EndProject
7−Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Skrepka", "Skrepka.fsproj", "{7242255B-8844-46E3-8185-AA02876BD3B5}"
8−EndProject
9−Global
10− GlobalSection(SolutionConfigurationPlatforms) = preSolution
11− Debug|Any CPU = Debug|Any CPU
12− Release|Any CPU = Release|Any CPU
13− EndGlobalSection
14− GlobalSection(ProjectConfigurationPlatforms) = postSolution
15− {7242255B-8844-46E3-8185-AA02876BD3B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
16− {7242255B-8844-46E3-8185-AA02876BD3B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
17− {7242255B-8844-46E3-8185-AA02876BD3B5}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
18− {7242255B-8844-46E3-8185-AA02876BD3B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
19− {7242255B-8844-46E3-8185-AA02876BD3B5}.Release|Any CPU.Build.0 = Release|Any CPU
20− {7242255B-8844-46E3-8185-AA02876BD3B5}.Release|Any CPU.Deploy.0 = Release|Any CPU
21− EndGlobalSection
22− GlobalSection(SolutionProperties) = preSolution
23− HideSolutionNode = FALSE
24− EndGlobalSection
25− GlobalSection(ExtensibilityGlobals) = postSolution
26− SolutionGuid = {61F7FB11-1E47-470C-91E2-47F8143E1572}
27− EndGlobalSection
28−EndGlobal
client/Store.fsdeleted−369
@@ -1,369 +0,0 @@
1−namespace Skrepka
2−
3−open System
4−open System.Data
5−open System.IO
6−open Donald
7−open Microsoft.Data.Sqlite
8−
9−module Store =
10−
11− type Contact =
12− { Pubkey: string
13− Nickname: string
14− DisplayName: string
15− Bio: string
16− Photo: byte[] option
17− AddedDate: DateTimeOffset }
18−
19− type Profile =
20− { DisplayName: string
21− Bio: string
22− Photo: byte[] option }
23−
24− [<RequireQualifiedAccess>]
25− type DeliveryStatus =
26− | Sent
27− | Delivered
28−
29− type MessageDirection =
30− | Incoming
31− | Outgoing of DeliveryStatus
32−
33− type ChatMessage =
34− { Id: string
35− Body: string
36− Timestamp: DateTimeOffset
37− Direction: MessageDirection }
38−
39− type Data =
40− { Contacts: Map<string, Contact>
41− Messages: Map<string, ChatMessage list>
42− ServerUrl: string
43− PollCursor: int64 }
44−
45− let private log msg = eprintfn $"[skrepka] {msg}"
46−
47− let private appDataDir =
48− let baseDir =
49− let appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
50−
51− if String.IsNullOrEmpty(appData) then
52− let personal = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
53−
54− if String.IsNullOrEmpty(personal) then
55− Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Skrepka")
56− else
57− Path.Combine(personal, "Skrepka")
58− else
59− Path.Combine(appData, "Skrepka")
60−
61− Directory.CreateDirectory(baseDir) |> ignore
62− baseDir
63−
64− let private dbPath = Path.Combine(appDataDir, "skrepka.db")
65−
66− let private openConn () =
67− let conn = new SqliteConnection($"Data Source={dbPath}")
68− conn.Open()
69− conn |> Db.newCommand "PRAGMA busy_timeout = 5000" |> Db.exec
70− conn
71−
72− // ── Readers ──
73−
74− let private readContact (rd: IDataReader) : Contact =
75− { Pubkey = rd.ReadString "Pubkey"
76− Nickname = rd.ReadString "Nickname"
77− DisplayName = rd.ReadString "DisplayName"
78− Bio = rd.ReadString "Bio"
79− Photo = rd.ReadBytesOption "Photo"
80− AddedDate = rd.ReadInt64 "AddedDateUnix" |> DateTimeOffset.FromUnixTimeMilliseconds }
81−
82− let private readMessage (rd: IDataReader) : ChatMessage =
83− let isOutgoing = rd.ReadBoolean "IsOutgoing"
84− let status = rd.ReadInt32 "Status"
85−
86− { Id = rd.ReadString "Id"
87− Body = rd.ReadString "Body"
88− Timestamp = rd.ReadInt64 "TimestampUnix" |> DateTimeOffset.FromUnixTimeMilliseconds
89− Direction =
90− if not isOutgoing then
91− Incoming
92− else
93− Outgoing(
94− if status = 1 then
95− DeliveryStatus.Delivered
96− else
97− DeliveryStatus.Sent
98− ) }
99−
100− // ── Schema ──
101−
102− do
103− use conn = openConn ()
104− conn |> Db.newCommand "PRAGMA journal_mode=WAL" |> Db.exec
105−
106− conn
107− |> Db.newCommand
108− "CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
109− |> Db.exec
110−
111− conn
112− |> Db.newCommand
113− "CREATE TABLE IF NOT EXISTS contacts (Pubkey TEXT PRIMARY KEY, Nickname TEXT NOT NULL DEFAULT '', DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB, AddedDateUnix INTEGER NOT NULL DEFAULT 0)"
114− |> Db.exec
115−
116− // Migration: add AddedDateUnix to existing contacts tables
117− try
118− conn
119− |> Db.newCommand "ALTER TABLE contacts ADD COLUMN AddedDateUnix INTEGER NOT NULL DEFAULT 0"
120− |> Db.exec
121− with _ ->
122− ()
123−
124− conn
125− |> Db.newCommand
126− "CREATE TABLE IF NOT EXISTS messages (Id TEXT PRIMARY KEY, ConversationId TEXT NOT NULL, Body TEXT NOT NULL DEFAULT '', TimestampUnix INTEGER NOT NULL, IsOutgoing INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL DEFAULT 0)"
127− |> Db.exec
128−
129− conn
130− |> Db.newCommand
131− "CREATE TABLE IF NOT EXISTS settings (Id TEXT PRIMARY KEY, ServerUrl TEXT NOT NULL DEFAULT '', PollCursor INTEGER NOT NULL DEFAULT 0)"
132− |> Db.exec
133−
134− conn
135− |> Db.newCommand
136− "CREATE TABLE IF NOT EXISTS outbox (Id INTEGER PRIMARY KEY AUTOINCREMENT, RecipientHex TEXT NOT NULL, EnvelopeJson TEXT NOT NULL, CreatedAt INTEGER NOT NULL)"
137− |> Db.exec
138−
139− conn
140− |> Db.newCommand "CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(ConversationId)"
141− |> Db.exec
142−
143− // ── Identity ──
144−
145− let private identityPath = Path.Combine(appDataDir, "identity.key")
146−
147− let loadIdentity () =
148− async {
149− return
150− try
151− if File.Exists(identityPath) then
152− File.ReadAllText(identityPath)
153− |> Option.ofObj
154− |> Option.filter (fun s -> s <> "")
155− |> Option.bind (Convert.FromBase64String >> Crypto.identityFromPrivKey)
156− else
157− None
158− with ex ->
159− log $"loadIdentity error: {ex.Message}"
160− None
161− }
162−
163− let saveIdentity (identity: Crypto.Identity) =
164− async {
165− let b64 = Convert.ToBase64String identity.PrivKey
166− File.WriteAllText(identityPath, b64)
167− }
168−
169− // ── Profile ──
170−
171− let loadProfile () =
172− async {
173− try
174− use conn = openConn ()
175−
176− return
177− conn
178− |> Db.newCommand "SELECT DisplayName, Bio, Photo FROM profile WHERE Id = @Id"
179− |> Db.setParams [ "Id", SqlType.String Constants.profileId ]
180− |> Db.querySingle (fun rd ->
181− { DisplayName = rd.ReadString "DisplayName"
182− Bio = rd.ReadString "Bio"
183− Photo = rd.ReadBytesOption "Photo" })
184− with ex ->
185− log $"loadProfile error: {ex.Message}"
186− return None
187− }
188−
189− let saveProfile (profile: Profile) =
190− async {
191− try
192− use conn = openConn ()
193−
194− conn
195− |> Db.newCommand
196− "INSERT OR REPLACE INTO profile (Id, DisplayName, Bio, Photo) VALUES (@Id, @DisplayName, @Bio, @Photo)"
197− |> Db.setParams
198− [ "Id", SqlType.String Constants.profileId
199− "DisplayName", SqlType.String profile.DisplayName
200− "Bio", SqlType.String profile.Bio
201− "Photo", SqlType.sqlBytesOrNull profile.Photo ]
202− |> Db.exec
203− with ex ->
204− log $"saveProfile error: {ex.Message}"
205− }
206−
207− // ── Data ──
208−
209− let loadData () =
210− async {
211− try
212− use conn = openConn ()
213−
214− let settings =
215− conn
216− |> Db.newCommand "SELECT ServerUrl, PollCursor FROM settings WHERE Id = @Id"
217− |> Db.setParams [ "Id", SqlType.String Constants.settingsId ]
218− |> Db.querySingle (fun rd -> rd.ReadString "ServerUrl", rd.ReadInt64 "PollCursor")
219−
220− match settings with
221− | None -> return None
222− | Some(serverUrl, pollCursor) ->
223− let contacts =
224− conn
225− |> Db.newCommand "SELECT Pubkey, Nickname, DisplayName, Bio, Photo, AddedDateUnix FROM contacts"
226− |> Db.query readContact
227− |> List.map (fun c -> c.Pubkey, c)
228− |> Map.ofList
229−
230− let messages =
231− conn
232− |> Db.newCommand
233− $"SELECT Id, Body, TimestampUnix, IsOutgoing, Status, ConversationId FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY ConversationId ORDER BY TimestampUnix DESC) AS rn FROM messages) WHERE rn <= {Constants.maxMessages}"
234− |> Db.query (fun rd -> rd.ReadString "ConversationId", readMessage rd)
235− |> List.groupBy fst
236− |> List.map (fun (convId, pairs) -> convId, pairs |> List.map snd |> List.sortBy _.Timestamp)
237− |> Map.ofList
238−
239− return
240− Some
241− { Contacts = contacts
242− Messages = messages
243− ServerUrl = serverUrl
244− PollCursor = pollCursor }
245− with ex ->
246− log $"loadData error: {ex.Message}"
247− return None
248− }
249−
250− let saveData (data: Data) =
251− async {
252− try
253− use conn = openConn ()
254− use tx = conn.BeginTransaction()
255−
256− for c in data.Contacts.Values do
257− conn
258− |> Db.newCommand
259− "INSERT OR REPLACE INTO contacts (Pubkey, Nickname, DisplayName, Bio, Photo, AddedDateUnix) VALUES (@Pubkey, @Nickname, @DisplayName, @Bio, @Photo, @AddedDateUnix)"
260− |> Db.setParams
261− [ "Pubkey", SqlType.String c.Pubkey
262− "Nickname", SqlType.String c.Nickname
263− "DisplayName", SqlType.String c.DisplayName
264− "Bio", SqlType.String c.Bio
265− "Photo",
266− (match c.Photo with
267− | Some bytes -> SqlType.Bytes bytes
268− | None -> SqlType.Null)
269− "AddedDateUnix", SqlType.Int64(c.AddedDate.ToUnixTimeMilliseconds()) ]
270− |> Db.exec
271−
272− for KeyValue(convId, msgs) in data.Messages do
273− for m in msgs do
274− let isOutgoing, status =
275− match m.Direction with
276− | Incoming -> false, 0
277− | Outgoing DeliveryStatus.Delivered -> true, 1
278− | Outgoing DeliveryStatus.Sent -> true, 0
279−
280− conn
281− |> Db.newCommand
282− "INSERT OR REPLACE INTO messages (Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status) VALUES (@Id, @ConversationId, @Body, @TimestampUnix, @IsOutgoing, @Status)"
283− |> Db.setParams
284− [ "Id", SqlType.String m.Id
285− "ConversationId", SqlType.String convId
286− "Body", SqlType.String m.Body
287− "TimestampUnix", SqlType.Int64(m.Timestamp.ToUnixTimeMilliseconds())
288− "IsOutgoing", SqlType.Boolean isOutgoing
289− "Status", SqlType.Int32 status ]
290− |> Db.exec
291−
292− conn
293− |> Db.newCommand
294− "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)"
295− |> Db.setParams
296− [ "Id", SqlType.String Constants.settingsId
297− "ServerUrl", SqlType.String data.ServerUrl
298− "PollCursor", SqlType.Int64 data.PollCursor ]
299− |> Db.exec
300−
301− tx.Commit()
302− with ex ->
303− log $"saveData error: {ex.Message}"
304− }
305−
306− // ── Outbox ──
307−
308− let enqueueOutbox (recipientHex: string) (envelopeJson: string) =
309− async {
310− try
311− use conn = openConn ()
312−
313− conn
314− |> Db.newCommand
315− "INSERT INTO outbox (RecipientHex, EnvelopeJson, CreatedAt) VALUES (@RecipientHex, @EnvelopeJson, @CreatedAt)"
316− |> Db.setParams
317− [ "RecipientHex", SqlType.String recipientHex
318− "EnvelopeJson", SqlType.String envelopeJson
319− "CreatedAt", SqlType.Int64(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) ]
320− |> Db.exec
321− with ex ->
322− log $"enqueueOutbox error: {ex.Message}"
323− }
324−
325− let peekOutbox () =
326− async {
327− try
328− use conn = openConn ()
329−
330− return
331− conn
332− |> Db.newCommand "SELECT Id, RecipientHex, EnvelopeJson, CreatedAt FROM outbox ORDER BY Id LIMIT 1"
333− |> Db.querySingle (fun rd ->
334− {| Id = rd.ReadInt64 "Id"
335− RecipientHex = rd.ReadString "RecipientHex"
336− EnvelopeJson = rd.ReadString "EnvelopeJson" |})
337− with ex ->
338− log $"peekOutbox error: {ex.Message}"
339− return None
340− }
341−
342− let dequeueOutbox (id: int64) =
343− async {
344− try
345− use conn = openConn ()
346−
347− conn
348− |> Db.newCommand "DELETE FROM outbox WHERE Id = @Id"
349− |> Db.setParams [ "Id", SqlType.Int64 id ]
350− |> Db.exec
351− with ex ->
352− log $"dequeueOutbox error: {ex.Message}"
353− }
354−
355− let loadMessagesForConversation (conversationId: string) =
356− async {
357− try
358− use conn = openConn ()
359−
360− return
361− conn
362− |> Db.newCommand
363− "SELECT Id, Body, TimestampUnix, IsOutgoing, Status FROM messages WHERE ConversationId = @convId ORDER BY TimestampUnix"
364− |> Db.setParams [ "convId", SqlType.String conversationId ]
365− |> Db.query readMessage
366− with ex ->
367− log $"loadMessagesForConversation error: {ex.Message}"
368− return []
369− }
client/Styles.fsdeleted−96
@@ -1,96 +0,0 @@
1−namespace Skrepka
2−
3−
4−module Styles =
5− open Avalonia
6− open Avalonia.Controls
7− open Avalonia.Controls.Presenters
8− open Avalonia.Media
9− open Avalonia.Styling
10− open Fabulous
11− open Fabulous.Avalonia
12−
13− open type Fabulous.Avalonia.View
14−
15− let style (selector: Selector -> Selector) (setters: Setter list) =
16− let s = Style(selector)
17−
18− for setter in setters do
19− s.Setters.Add(setter)
20−
21− s :> IStyle
22−
23− let withBottomBar safeAreaBottom (bar: WidgetBuilder<'msg, 'a>) (content: WidgetBuilder<'msg, 'b>) =
24− Grid([], [ Dimension.Star; Dimension.Auto ]) {
25− AnyView(content).gridRow (0)
26−
27− Border(AnyView(bar))
28− .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
29− .borderBrush(SolidColorBrush(Colors.Black))
30− .background(Constants.accentColor)
31− .padding(Avalonia.Thickness(0., 0., 0., safeAreaBottom))
32− .gridRow (1)
33− }
34−
35− let noListBoxPadding () =
36− [ style
37− _.OfType<ListBoxItem>()
38− [ Setter(ListBoxItem.PaddingProperty, Thickness(0.))
39− Setter(ListBoxItem.MinHeightProperty, box 0.) ]
40− style
41− (_.OfType<ListBoxItem>().Template().OfType<ContentPresenter>().Name("PART_ContentPresenter"))
42− [ Setter(ContentPresenter.MarginProperty, Thickness(0.)) ] ]
43−
44− let noListBoxSelection () =
45− [ style
46− (_.OfType<ListBoxItem>()
47− .Class(":selected")
48− .Template()
49− .OfType<ContentPresenter>()
50− .Name("PART_ContentPresenter"))
51− [ Setter(ContentPresenter.BackgroundProperty, Brushes.Transparent) ]
52− style
53− (_.OfType<ListBoxItem>()
54− .Class(":pointerover")
55− .Template()
56− .OfType<ContentPresenter>()
57− .Name("PART_ContentPresenter"))
58− [ Setter(ContentPresenter.BackgroundProperty, Brushes.Transparent) ] ]
59−
60− let private bitmapCache =
61− System.Collections.Concurrent.ConcurrentDictionary<int, Avalonia.Media.Imaging.Bitmap>()
62−
63− let private bitmapInsertOrder = System.Collections.Concurrent.ConcurrentQueue<int>()
64−
65− let cachedBitmap (bytes: byte[]) =
66− let h =
67− System.HashCode.Combine(
68− bytes.Length,
69− (if bytes.Length > 3 then
70− System.BitConverter.ToInt32(bytes, 0)
71− else
72− 0),
73− (if bytes.Length > 7 then
74− System.BitConverter.ToInt32(bytes, bytes.Length / 2)
75− else
76− 0),
77− (if bytes.Length > 11 then
78− System.BitConverter.ToInt32(bytes, bytes.Length - 4)
79− else
80− 0)
81− )
82−
83− while bitmapCache.Count >= Constants.maxBitmapCacheSize do
84− match bitmapInsertOrder.TryDequeue() with
85− | true, oldest ->
86− match bitmapCache.TryRemove(oldest) with
87− | true, bmp -> bmp.Dispose()
88− | _ -> ()
89− | _ -> bitmapCache.Clear()
90−
91− bitmapCache.GetOrAdd(
92− h,
93− fun _ ->
94− bitmapInsertOrder.Enqueue(h)
95− new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(bytes))
96− )
client/TextFields.fsdeleted−48
@@ -1,48 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module TextFields =
10− open Avalonia.Layout
11− open Avalonia.Styling
12−
13− let private whiteBg () =
14− let bg = Avalonia.Media.Immutable.ImmutableSolidColorBrush(Colors.White)
15− let setter = Setter(Avalonia.Controls.Border.BackgroundProperty, bg)
16−
17− [ Skrepka.Styles.style
18− (_.OfType<Avalonia.Controls.TextBox>()
19− .Class(":pointerover")
20− .Template()
21− .OfType<Avalonia.Controls.Border>()
22− .Name("PART_BorderElement"))
23− [ setter ]
24− Skrepka.Styles.style
25− (_.OfType<Avalonia.Controls.TextBox>()
26− .Class(":focus")
27− .Template()
28− .OfType<Avalonia.Controls.Border>()
29− .Name("PART_BorderElement"))
30− [ setter ] ]
31−
32− let textField watermark value msg =
33− (Grid() {
34− TextBox(value, msg).watermark(watermark).fontFamily(Constants.fontFamily).verticalAlignment(VerticalAlignment.Stretch).verticalContentAlignment(VerticalAlignment.Center).background(SolidColorBrush(Colors.White))
35− Rectangle().stroke(Colors.Black).strokeThickness (4.)
36− }).styles (whiteBg ())
37−
38− let errorTextField value msg =
39− (Grid() {
40− TextBox(value, msg).fontFamily(Constants.fontFamily).verticalAlignment(VerticalAlignment.Stretch).verticalContentAlignment(VerticalAlignment.Center).background(SolidColorBrush(Colors.White))
41− Rectangle().stroke(Colors.Red).strokeThickness (4.)
42− }).styles (whiteBg ())
43−
44− let multilineTextField value msg =
45− Grid() {
46− TextBox(value, msg).fontFamily(Constants.fontFamily).acceptsReturn(true).height (150)
47− Rectangle().stroke(Colors.Black).strokeThickness (4.)
48− }
client/ViewAddContact.fsdeleted−70
@@ -1,70 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewAddContact =
9−
10− open AppTypes
11− open Buttons
12− open Styles
13− open Labels
14− open TextFields
15−
16− let private qrBytes (text: string) =
17− use qr = new QRCoder.QRCodeGenerator()
18− use data = qr.CreateQrCode(text, QRCoder.QRCodeGenerator.ECCLevel.M)
19− use png = new QRCoder.PngByteQRCode(data)
20− png.GetGraphic(8, [| 0uy; 0uy; 0uy |], [| 255uy; 255uy; 255uy |])
21−
22− let viewAddContact model (cpk: string) =
23− let content =
24− ScrollViewer(
25− (VStack(16.) {
26−
27− h2 ("Share your address")
28−
29− match model.Auth with
30− | Identified(id, _) ->
31− let ob = hexToOb id.PubKeyHex
32−
33− Image(Styles.cachedBitmap (qrBytes ob), Stretch.Uniform).maxWidth(200.).centerHorizontal ()
34−
35− body(ob).fontSize(12.).centerText ()
36−
37− smallButton Primary "Copy Address" CopyPubKey
38− | NoIdentity -> ()
39−
40− h2 "Enter someone else's address"
41−
42− smallButton Secondary "Scan QR Code" DoScanQR
43−
44− let cpkTrimmed: string = cpk.Trim()
45−
46− let isValid =
47− Phonemic.fromOb cpkTrimmed
48− |> Option.orElseWith (fun () -> Crypto.tryFromHex cpkTrimmed)
49− |> Option.filter (fun bytes -> bytes.Length = 32)
50− |> Option.isSome
51−
52− let hasError = cpkTrimmed <> "" && not isValid
53−
54− if hasError then
55− errorTextField cpk (fun key -> SetPage(AddContact(key)))
56− else
57− textField "" cpk (fun key -> SetPage(AddContact(key)))
58−
59−
60− if isValid then
61− smallButton Primary "Add" DoSaveContact
62− else
63− disabledSmallButton Primary "Add" DoSaveContact
64− })
65− .margin (20.)
66− )
67−
68− let bar = HStack(8.) { backButton (SetPage Conversations) }
69−
70− withBottomBar model.SafeAreaBottom bar content
client/ViewChat.fsdeleted−172
@@ -1,172 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Layout
4−open Avalonia.Styling
5−open Fabulous.Avalonia
6−
7−open type Fabulous.Avalonia.View
8−
9−module ViewChat =
10−
11− open Store
12− open AppTypes
13− open Buttons
14− open TextFields
15−
16− let private ensureMarchingAntsStyle =
17− lazy
18− (let s =
19− Avalonia.Styling.Style(fun sel ->
20− sel.OfType<Avalonia.Controls.Shapes.Rectangle>().Class("marching"))
21−
22− let anim = Avalonia.Animation.Animation()
23− anim.Duration <- System.TimeSpan.FromSeconds 3.
24− anim.IterationCount <- Avalonia.Animation.IterationCount.Infinite
25− anim.Easing <- Avalonia.Animation.Easings.LinearEasing()
26− let kf1 = Avalonia.Animation.KeyFrame()
27−
28− kf1.Setters.Add(
29− Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 0.)
30− )
31−
32− kf1.Cue <- Avalonia.Animation.Cue(0.)
33− let kf2 = Avalonia.Animation.KeyFrame()
34−
35− kf2.Setters.Add(
36− Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 6.)
37− )
38−
39− kf2.Cue <- Avalonia.Animation.Cue(1.)
40− anim.Children.Add(kf1)
41− anim.Children.Add(kf2)
42− s.Animations.Add(anim)
43− Avalonia.Application.Current.Styles.Add(s))
44−
45− let private scrollRef = Fabulous.ViewRef<Avalonia.Controls.ScrollViewer>()
46− let mutable private lastMsgCount = 0
47− let mutable private pageSize = 50
48− let mutable private loadingMore = false
49− let mutable private isAtBottom = true
50−
51− let private scrollToEnd () =
52− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
53− match scrollRef.TryValue with
54− | Some sv -> sv.ScrollToEnd()
55− | None -> ()
56− , Avalonia.Threading.DispatcherPriority.Background) |> ignore
57−
58− let private messageItem m =
59− let align, cols, col =
60− match m.Direction with
61− | Outgoing _ -> HorizontalAlignment.Right, [ Dimension.Stars 3.; Dimension.Stars 7. ], 1
62− | Incoming -> HorizontalAlignment.Left, [ Dimension.Stars 7.; Dimension.Stars 3. ], 0
63−
64− Grid(cols, [ Dimension.Auto ]) {
65− Border(
66− (Grid() {
67− TextBlock(m.Body)
68− .fontSize(14.)
69− .fontFamily(Constants.fontFamily)
70− .fontWeight(Avalonia.Media.FontWeight.Bold)
71− .textWrapping(Avalonia.Media.TextWrapping.Wrap)
72− .padding (8.)
73−
74− match m.Direction with
75− | Outgoing status ->
76− let isSent = status = DeliveryStatus.Sent
77−
78− if isSent then
79− ensureMarchingAntsStyle.Force()
80−
81− Rectangle()
82− .stroke(Avalonia.Media.Colors.Black)
83− .strokeThickness(4.)
84− .strokeDashArray([ 4.; 2. ])
85− .isHitTestVisible(false)
86− .classes("marching")
87− else
88− Rectangle()
89− .stroke(Avalonia.Media.Colors.Black)
90− .strokeThickness(4.)
91− .isHitTestVisible(false)
92− | Incoming ->
93− Rectangle()
94− .stroke(Avalonia.Media.Colors.Black)
95− .strokeThickness(4.)
96− .isHitTestVisible (false)
97− }))
98− .horizontalAlignment(align)
99− .margin(4.)
100− .gridColumn (col)
101− }
102−
103− let viewChat model pk compose =
104− let name = contactName model.Contacts pk
105− let msgs = messagesFor pk model.Messages
106−
107− let bar =
108− (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], [ Dimension.Auto ]) {
109− (backButton (SetPage Conversations)).gridColumn (0)
110−
111− (textField "Message..." compose (fun text -> SetPage(Chat(pk, text))))
112− .margin(8.)
113− .gridColumn (1)
114−
115− (smallImageButton "avares://Skrepka/Assets/Images/send.png" DoSend).gridColumn (2)
116− })
117− .horizontalAlignment (HorizontalAlignment.Stretch)
118−
119− let content =
120− (Grid([ Dimension.Star ], [ Dimension.Auto; Dimension.Auto; Dimension.Star ]) {
121− TextBlock(name).fontSize(20.).padding(8.).gridRow (0)
122−
123− (viewErrorBanner model.Error).gridRow (1)
124−
125− (let msgView =
126− if msgs.IsEmpty then
127− lastMsgCount <- 0
128− pageSize <- 50
129− AnyView(TextBlock("No messages yet").padding(8.).fontSize (14.))
130− else
131− if msgs.Length <> lastMsgCount then
132− lastMsgCount <- msgs.Length
133− if isAtBottom then scrollToEnd ()
134−
135− let skip = max 0 (msgs.Length - pageSize)
136− let visible = msgs |> List.skip skip
137−
138− AnyView(
139− ScrollViewer(
140− VStack(0.) {
141− for m in visible do
142− messageItem m
143− })
144− .reference(scrollRef)
145− .onLoaded(fun _ ->
146− pageSize <- 50
147− loadingMore <- false
148− isAtBottom <- true
149− scrollToEnd ()
150− DismissError)
151− .onScrollChanged(fun args ->
152− let sv = args.Source :?> Avalonia.Controls.ScrollViewer
153− isAtBottom <- sv.Offset.Y + sv.Viewport.Height >= sv.Extent.Height - 20.
154− if sv.Offset.Y < 50. && not loadingMore && skip > 0 then
155− loadingMore <- true
156− let oldHeight = sv.Extent.Height
157− pageSize <- min msgs.Length (pageSize + 50)
158− Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
159− match scrollRef.TryValue with
160− | Some sv2 ->
161− let newHeight = sv2.Extent.Height
162− sv2.Offset <- Avalonia.Vector(0., sv2.Offset.Y + (newHeight - oldHeight))
163− loadingMore <- false
164− | None -> loadingMore <- false
165− , Avalonia.Threading.DispatcherPriority.Background) |> ignore
166− SetPage(Chat(pk, compose))))
167−
168− msgView).gridRow (2)
169− })
170−
171− let barInset = if model.KeyboardHeight > 0. then 0. else model.SafeAreaBottom
172− Styles.withBottomBar barInset bar content
client/ViewConversations.fsdeleted−101
@@ -1,101 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewConversations =
9−
10− open AppTypes
11− open Buttons
12− open TextFields
13− open Styles
14− open Avalonia.Layout
15−
16− let viewConversations model =
17− let contacts =
18− model.Contacts.Values
19− |> Seq.toList
20− |> List.sortBy (fun c ->
21− messagesFor c.Pubkey model.Messages
22− |> List.tryLast
23− |> Option.map (fun m -> m.Timestamp)
24− |> Option.defaultValue c.AddedDate)
25−
26− let content =
27− if contacts.IsEmpty then
28− AnyView(
29− TextBlock("No contacts yet. Tap + to add one.")
30− .centerText()
31− .foreground (SolidColorBrush(Colors.Gray))
32− )
33− else
34− AnyView(
35− ScrollViewer(
36− VStack(0.) {
37− for c in contacts do
38− let name = contactName model.Contacts c.Pubkey
39−
40− let preview =
41− messagesFor c.Pubkey model.Messages
42− |> List.tryLast
43− |> Option.map (fun m ->
44− if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
45− |> Option.defaultValue ""
46−
47− Border(
48− (HStack(8.) {
49−
50− (Grid() {
51− (match c.Photo with
52− | None ->
53− Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
54− .margin (8.)
55− | Some i -> Image(cachedBitmap i, Stretch.UniformToFill))
56− .clipToBounds (true)
57−
58− Rectangle().stroke(Colors.Black).strokeThickness (4.)
59− })
60− .width(40.)
61− .height (40.)
62−
63− VStack(2.) {
64− TextBlock(name)
65− .fontFamily(Constants.fontFamily)
66− .fontWeight(FontWeight.Bold)
67− .fontSize (16.)
68−
69− if preview <> "" then
70− TextBlock(preview).fontSize(12.).fontWeight (FontWeight.Bold)
71− }
72−
73− })
74− .margin (8.)
75− )
76− .borderThickness(Avalonia.Thickness(0., 0., 0., 4.))
77− .borderBrush(SolidColorBrush(Colors.Black))
78− .background(Colors.Transparent)
79− .onTapped (fun _ -> SetPage(Chat(c.Pubkey, "")))
80− }
81− )
82− .background(Colors.White)
83− .verticalAlignment (VerticalAlignment.Bottom)
84− )
85−
86− let bar =
87− VStack(0.) {
88− TextBlock($"{connStatusLabel model.Auth} | {model.PollStatus}")
89− .fontSize(10.)
90− .foreground(SolidColorBrush(Colors.Gray))
91− .centerText ()
92−
93− (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], [ Dimension.Auto ]) {
94− (smallImageButton "avares://Skrepka/Assets/Images/user.png" (SetPage Settings)).gridColumn (0)
95− (textField "" "" Search).margin(8.).verticalAlignment(VerticalAlignment.Stretch).gridColumn (1)
96− (smallTextButton "+" (SetPage(AddContact("")))).gridColumn (2)
97− })
98− .horizontalAlignment (HorizontalAlignment.Stretch)
99− }
100−
101− withBottomBar model.SafeAreaBottom bar content
client/ViewEditProfile.fsdeleted−41
@@ -1,41 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewEditProfile =
9−
10− open AppTypes
11− open Buttons
12− open TextFields
13− open Styles
14−
15− let viewEditProfile model displayName bio (photo: byte[] option) =
16− let content =
17− ScrollViewer(
18− (VStack(16.) {
19− viewErrorBanner model.Error
20−
21− imageButton photo DoPickPhoto
22−
23− TextBlock("Name").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 24, 0, -8)
24− (textField "" displayName (fun text -> SetPage(EditProfile(text, bio, photo)))).margin (0, 0, 0, 8)
25−
26−
27− TextBlock("Bio").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 0, 0, -8)
28−
29− (multilineTextField bio (fun text -> SetPage(EditProfile(displayName, text, photo))))
30− .margin (0, 0, 0, 8)
31− })
32− .margin (20.)
33− )
34−
35− let bar =
36− HStack(8.) {
37− backButton (SetPage Settings)
38− (button Secondary "save" DoSaveProfile).margin (8.)
39− }
40−
41− withBottomBar model.SafeAreaBottom bar content
client/ViewSettings.fsdeleted−57
@@ -1,57 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewSettings =
9−
10− open AppTypes
11−
12− let viewSettings model =
13− ScrollViewer(
14− (VStack(16.) {
15− TextBlock("Settings").fontSize(24.).centerText ()
16−
17− match model.Auth with
18− | Identified(id, conn) ->
19− TextBlock("Your Public Key:")
20−
21− TextBlock(truncKey id.PubKeyHex).fontSize (14.)
22−
23− Button("Copy Public Key", CopyPubKey)
24−
25− TextBlock("Profile:").fontSize (18.)
26−
27− let displayName, bio, photo =
28− match model.Profile with
29− | Some p -> p.DisplayName, p.Bio, p.Photo
30− | None -> "", "", None
31−
32− if displayName <> "" then
33− TextBlock($"Name: {displayName}").fontSize (14.)
34−
35− if bio <> "" then
36− TextBlock($"Bio: {bio}").fontSize(14.).foreground (SolidColorBrush(Colors.DimGray))
37−
38− Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
39−
40− TextBlock("Server:")
41−
42− match conn with
43− | Online _ -> TextBlock(model.ServerUrl).fontSize (14.)
44− | _ -> TextBox(model.ServerUrl, SetServerUrl)
45−
46− viewErrorBanner model.Error
47−
48− match conn with
49− | Offline -> Button("Connect", DoConnect)
50− | Connecting -> TextBlock("Connecting...").centerText ()
51− | Online _ ->
52− Button("Disconnect", DoDisconnect)
53− Button("Go to Conversations", SetPage Conversations)
54− | NoIdentity -> ()
55− })
56− .margin (20.)
57− )
client/ViewSetup.fsdeleted−30
@@ -1,30 +0,0 @@
1−namespace Skrepka
2−
3−open Avalonia.Media
4−open Fabulous.Avalonia
5−
6−open type Fabulous.Avalonia.View
7−
8−module ViewSetup =
9−
10− open AppTypes
11− open Buttons
12−
13− let viewSetup () =
14− (VStack(24.) {
15− Image("avares://Skrepka/Assets/Images/logo.png", Avalonia.Media.Stretch.Uniform)
16− .margin(8., 0., 8., 0.)
17− .maxWidth (300.)
18−
19− TextBlock("Skrepka")
20− .fontSize(32.)
21− .fontFamily(FontFamily("avares://Skrepka/Assets/Fonts#Unbounded"))
22− .fontWeight(FontWeight.Bold)
23− .centerText ()
24−
25− TextBlock("").height (50.)
26−
27− button Primary "Next" GenIdentity
28− })
29− .margin(30.)
30− .centerVertical ()
client/msbuild.binlogdeleted

Binary file not shown.

client/native/iossimulator-arm64/libsodium.adeleted

Binary file not shown.

client/native/maccatalyst-arm64/libsodium.adeleted

Binary file not shown.

client/run-ios.shdeleted−31
@@ -1,31 +0,0 @@
1−#!/bin/bash
2−set -e
3−cd "$(dirname "$0")"
4−
5−DEVICE="${1:-iPhone 17 Pro}"
6−BUNDLE_ID="com.skrepka.client"
7−APP="bin/Debug/net10.0-ios/iossimulator-arm64/Skrepka.app"
8−
9−dotnet build Skrepka.fsproj -f net10.0-ios
10−
11−UDID=$(xcrun simctl list devices available -j | python3 -c "
12−import json, sys
13−devices = json.load(sys.stdin)['devices']
14−name = '$DEVICE'
15−for runtime, devs in devices.items():
16− for d in devs:
17− if d['name'] == name and d['isAvailable']:
18− print(d['udid']); sys.exit()
19−print('', end=''); sys.exit(1)
20−")
21−
22−if [ -z "$UDID" ]; then
23− echo "Simulator '$DEVICE' not found. Available:"
24− xcrun simctl list devices available | grep -i iphone
25− exit 1
26−fi
27−
28−xcrun simctl boot "$UDID" 2>/dev/null || true
29−open -a Simulator
30−xcrun simctl install "$UDID" "$APP"
31−xcrun simctl launch "$UDID" "$BUNDLE_ID"
client/run-mac.shdeleted−6
@@ -1,6 +0,0 @@
1−#!/bin/bash
2−set -e
3−cd "$(dirname "$0")"
4−dotnet build -f net10.0
5−export DOTNET_ROOT="$(dotnet --info | grep 'Base Path' | sed 's/.*: *//' | sed 's|/sdk/.*||')"
6−exec bin/Debug/net10.0/Skrepka.app/Contents/MacOS/Skrepka
core/Cargo.tomlnew file+60
@@ -0,0 +1,60 @@
1+[package]
2+name = "skrepka_core"
3+version = "0.1.0"
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+repository.workspace = true
8+
9+[lib]
10+name = "skrepka_core"
11+crate-type = ["cdylib", "lib", "staticlib"]
12+
13+[[bin]]
14+name = "codegen"
15+required-features = ["codegen"]
16+
17+[features]
18+facet_typegen = ["crux_core/facet_typegen"]
19+codegen = [
20+ "dep:anyhow",
21+ "dep:clap",
22+ "dep:log",
23+ "dep:pretty_env_logger",
24+ "facet_typegen",
25+]
26+
27+[dependencies]
28+crux_core.workspace = true
29+crux_http.workspace = true
30+crux_kv.workspace = true
31+crux_time.workspace = true
32+boltffi.workspace = true
33+facet.workspace = true
34+serde.workspace = true
35+serde_json.workspace = true
36+
37+# crypto
38+ed25519-dalek.workspace = true
39+x25519-dalek.workspace = true
40+curve25519-dalek.workspace = true
41+chacha20poly1305.workspace = true
42+hkdf.workspace = true
43+sha2.workspace = true
44+zstd.workspace = true
45+rand_core.workspace = true
46+uuid.workspace = true
47+hex.workspace = true
48+base64.workspace = true
49+zeroize.workspace = true
50+url.workspace = true
51+
52+# optional, only for the codegen bin
53+anyhow = { workspace = true, optional = true }
54+clap = { workspace = true, optional = true }
55+log = { workspace = true, optional = true }
56+pretty_env_logger = { workspace = true, optional = true }
57+
58+[dev-dependencies]
59+rand_chacha.workspace = true
60+crux_core = { workspace = true, features = ["testing"] }
core/boltffi.tomlnew file+24
@@ -0,0 +1,24 @@
1+[package]
2+name = "skrepka_core"
3+crate = "skrepka_core"
4+version = "0.1.0"
5+repository = "https://github.com/ilyakooo0/skrepka"
6+license = "MIT"
7+
8+[cargo]
9+global_args = []
10+
11+[cargo.command_args]
12+build = ["--features", "facet_typegen"]
13+
14+[targets.apple]
15+output = "../apple/generated/Shared"
16+deployment_target = "17.0"
17+include_macos = false
18+
19+[targets.apple.swift]
20+module_name = "SkrepkaShared"
21+
22+[targets.apple.spm]
23+package_name = "SkrepkaShared"
24+layout = "ffi-only"
core/src/app.rsnew file+5027
@@ -0,0 +1,5027 @@
1+//! Crux App — the MVU state machine for the skrepka client.
2+//!
3+//! The core owns all business logic: identity, auth, the long-poll loop, the
4+//! outbox, contacts, profiles, and message ingest. The shell provides HTTP,
5+//! key-value storage, a timer, and (natively, fed back as events) the Keychain,
6+//! QR scanning, photo picking, and clipboard.
7+
8+use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
9+use std::sync::Arc;
10+use std::time::{Duration, SystemTime, UNIX_EPOCH};
11+
12+use base64::prelude::*;
13+use base64::Engine;
14+
15+use crux_core::{
16+ macros::effect,
17+ render::{render, RenderOperation},
18+ App, Command,
19+};
20+use crux_http::protocol::HttpRequest;
21+use crux_kv::KeyValueOperation;
22+use crux_time::TimeRequest;
23+use facet::Facet;
24+use serde::{Deserialize, Serialize};
25+use url::Url;
26+use zeroize::Zeroizing;
27+
28+use crate::crypto::{Identity, MAX_BLOB_LEN};
29+use crate::model::{
30+ hex_to_ob, trunc_ob, Contact, ContactVM, MessageVM, OutboxItem, OwnProfile, ProfileVM,
31+ Settings, StoredMessage, ViewModel, DEFAULT_SERVER_URL, MAX_MESSAGES_PER_PEER,
32+};
33+use crate::phonemic;
34+use crate::protocol::{self, Envelope, Payload};
35+
36+type Http = crux_http::command::Http<Effect, Event>;
37+type KeyValue = crux_kv::KeyValue<Effect, Event>;
38+type Time = crux_time::Time<Effect, Event>;
39+type HttpResult = crux_http::Result<crux_http::Response<Vec<u8>>>;
40+type KvData = Result<Option<Vec<u8>>, crux_kv::error::KeyValueError>;
41+
42+/// How far into the future an incoming, sender-chosen `ts` may sit before it is
43+/// clamped to "now" — enough slack for honest clock skew, not enough to let a
44+/// peer park itself at the top of the conversation list.
45+const MAX_FUTURE_SKEW_MS: i64 = 60_000;
46+
47+/// Cap on stored contacts.
48+///
49+/// Any stranger can create one just by sending us a message, so without a cap a
50+/// spammer holding a list of pubkeys can grow `contacts` (and the `contacts` kv
51+/// blob, rewritten in full on every change) without bound. Past the cap we drop
52+/// mail from unknown keys; contacts the user added by hand are never at risk,
53+/// because `AddContact` doesn't go through this path.
54+const MAX_CONTACTS: usize = 500;
55+
56+/// Cap on the total blob bytes we will decrypt out of a single poll page.
57+///
58+/// A hostile relay can answer `/poll` with as many events as it likes. Each one
59+/// costs a hex decode, a scalar mult and an AEAD pass, all before we can tell it
60+/// is junk. `MAX_BLOB_LEN` bounds one blob; this bounds the page. Anything past
61+/// the budget is dropped exactly like an undecryptable blob would be.
62+const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
63+
64+/// Cap on the number of events we will attempt to decrypt out of a single poll
65+/// page, independent of the byte budget. Minimum-size blobs (173 bytes decoded)
66+/// would otherwise permit ~388K events within the 64 MiB byte budget, each
67+/// costing a full scalar mult + SHA-512 + AEAD pass — enough to freeze the app
68+/// for tens of seconds per poll. An honest relay caps at 50 events
69+/// (`maxPollEvents`); this is a hostile-relay bound. Set above `MAX_ACK_IDS`
70+/// (1024) so a legitimate large page from one sender still fully processes.
71+const MAX_POLL_EVENTS: usize = 2000;
72+
73+/// Upper bound on a poll response body we will buffer and parse, in bytes.
74+///
75+/// Before the per-blob byte/count budgets in `ingest_poll` ever run, the client
76+/// reads the entire response body into a `Vec<u8>` and deserializes it. A
77+/// multi-gigabyte response from a hostile relay would OOM the app before
78+/// `ingest_poll`'s defenses are consulted. This cap (generously above what 50
79+/// max-size blobs + JSON envelope could cost) bounds the allocation.
80+const MAX_POLL_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
81+
82+/// How long a poll may stay in flight before we assume the shell lost it.
83+///
84+/// `polling` gates the whole long-poll loop, and it is only cleared by a
85+/// `PollResult`. If the shell ever drops an HTTP effect (a cancelled task, a
86+/// backgrounded app, a bug), `polling` stays `true` and the client goes quiet
87+/// forever with no visible error. The watchdog re-arms the loop instead.
88+///
89+/// This **must** stay above the shell's own HTTP timeout (70 s, see
90+/// `Effects.swift`), or the watchdog stops being a watchdog: it would fire on
91+/// every poll that is merely slow rather than lost, abandoning a request the
92+/// shell is still going to resolve. The generation check in `PollResult` makes
93+/// that survivable, but each misfire still costs a redundant request to the
94+/// relay, so leave the margin.
95+const POLL_WATCHDOG_MS: u64 = 90_000;
96+
97+/// How long a send may stay in flight before we assume the shell lost it.
98+///
99+/// `flushing` gates the whole send loop, and it is only cleared by a
100+/// `SendResult`. If the shell ever drops the HTTP effect (a cancelled task, a
101+/// backgrounded app, a bug), `flushing` stays `true` and the outbox is
102+/// permanently stuck — new messages queue up but never send. The watchdog
103+/// releases the guard so the next `StartFlush` can retry.
104+///
105+/// Kept above the shell's 70 s HTTP timeout for the same reason as
106+/// `POLL_WATCHDOG_MS`: a misfire on a merely slow send is survivable (the late
107+/// `SendResult` finds `in_flight` already cleared and pops nothing), but each
108+/// one costs a redundant request.
109+const FLUSH_WATCHDOG_MS: u64 = 90_000;
110+
111+/// How long an auth round-trip may stay in flight before we assume the
112+/// shell lost it. Same rationale as `POLL_WATCHDOG_MS`: `authenticating`
113+/// gates the whole auth flow, and without a watchdog a dropped effect
114+/// leaves it stuck forever. Kept above the 70 s shell HTTP timeout.
115+const AUTH_WATCHDOG_MS: u64 = 90_000;
116+
117+/// Messages per `/messages` request.
118+///
119+/// The relay rejects a longer batch outright (`413 batch_too_large`,
120+/// PROTOCOL.md §7), so this is a hard ceiling, not a tuning knob.
121+const MAX_SEND_BATCH: usize = 100;
122+
123+/// ...and a byte ceiling on the same batch, counted in hex characters — i.e. in
124+/// the units the request body is actually built from.
125+///
126+/// The count limit alone is not enough: a hundred profile broadcasts each
127+/// carrying a 64 KiB photo would build a request far past the 16 MiB body cap a
128+/// relay has by default (`--http-max-body-bytes`, PROTOCOL.md §7), and the whole
129+/// batch would come back `413` — which we read as a transient failure and retry,
130+/// forever. Bounding the body here keeps the batch inside what any conforming
131+/// relay accepts. A single item over the budget still goes out alone: it is the
132+/// relay's job to reject it, and its rejection is per-message and permanent.
133+const MAX_SEND_BATCH_BYTES: usize = 4 * 1024 * 1024;
134+
135+/// Ceiling on a `Retry-After` we will honour from a relay.
136+///
137+/// The header is a number the relay chooses and we obey, so without a cap a
138+/// single `Retry-After: 999999999` — from a hostile relay, or a merely broken
139+/// one — parks the outbox for thirty years with nothing on screen to say why.
140+/// Five minutes is longer than any real rate-limit window and still recovers on
141+/// its own.
142+const MAX_RETRY_AFTER_MS: u64 = 5 * 60 * 1000;
143+
144+// kv keys
145+const K_SETTINGS: &str = "settings";
146+const K_PROFILE: &str = "profile";
147+const K_CONTACTS: &str = "contacts";
148+const K_CURSOR: &str = "cursor";
149+const K_OUTBOX: &str = "outbox";
150+const K_SEEN_IDS: &str = "seen_ids";
151+fn k_messages(peer: &str) -> String {
152+ format!("messages:{peer}")
153+}
154+
155+/// N7: Cap on the per-peer `seen_ids` dedup set. Without a cap the set grows
156+/// without bound — a peer who sends thousands of messages accumulates
157+/// thousands of IDs, and the `seen_ids` kv blob is rewritten in full on every
158+/// incoming message. Kept well above `MAX_MESSAGES_PER_PEER` (1000) so a
159+/// trim-and-replay within the conversation window still hits the set.
160+const MAX_SEEN_IDS_PER_PEER: usize = 2000;
161+
162+// ---------------------------------------------------------------------------
163+// Effect
164+// ---------------------------------------------------------------------------
165+
166+#[effect(facet_typegen)]
167+#[derive(Debug)]
168+pub enum Effect {
169+ Render(RenderOperation),
170+ Http(HttpRequest),
171+ KeyValue(KeyValueOperation),
172+ Time(TimeRequest),
173+}
174+
175+// ---------------------------------------------------------------------------
176+// Events
177+// ---------------------------------------------------------------------------
178+
179+#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
180+#[repr(C)]
181+pub enum Event {
182+ // ---- lifecycle / persistence (from the shell) ----
183+ /// 64-byte Ed25519 secret key loaded (or freshly generated) by the shell.
184+ IdentityLoaded(Vec<u8>),
185+
186+ // ---- navigation (from the shell) ----
187+ ShowConversations,
188+ ShowSettings,
189+ ShowAddContact,
190+ ShowEditProfile,
191+ OpenChat(String),
192+ Back,
193+
194+ // ---- user actions (from the shell) ----
195+ ComposeChanged(String),
196+ SendText,
197+ AddContact { input: String, nickname: String },
198+ SetBlocked { peer: String, blocked: bool },
199+ /// Forget a peer entirely: the contact, the conversation, and anything still
200+ /// queued for them. Blocking only silences a peer; the entry stays in the
201+ /// list forever, and there was no way to be rid of it.
202+ DeleteContact { peer: String },
203+ SaveProfile {
204+ display_name: String,
205+ bio: String,
206+ photo: Option<String>,
207+ },
208+ SetServerUrl(String),
209+ Connect,
210+
211+ // ---- internal: kv load results ----
212+ #[serde(skip)]
213+ #[facet(skip)]
214+ LoadedSettings(#[facet(opaque)] KvData),
215+ #[serde(skip)]
216+ #[facet(skip)]
217+ LoadedProfile(#[facet(opaque)] KvData),
218+ #[serde(skip)]
219+ #[facet(skip)]
220+ LoadedContacts(#[facet(opaque)] KvData),
221+ #[serde(skip)]
222+ #[facet(skip)]
223+ LoadedCursor(#[facet(opaque)] KvData),
224+ #[serde(skip)]
225+ #[facet(skip)]
226+ LoadedOutbox(#[facet(opaque)] KvData),
227+ #[serde(skip)]
228+ #[facet(skip)]
229+ LoadedSeenIds(#[facet(opaque)] KvData),
230+ #[serde(skip)]
231+ #[facet(skip)]
232+ LoadedMessages(String, #[facet(opaque)] KvData),
233+ #[serde(skip)]
234+ #[facet(skip)]
235+ Saved(#[facet(opaque)] KvData),
236+ /// The cursor write specifically: the next poll is chained off it, so that a
237+ /// message is never acked to the server before it is durable locally.
238+ #[serde(skip)]
239+ #[facet(skip)]
240+ SavedCursor(#[facet(opaque)] KvData),
241+
242+ // ---- internal: auth / poll / send ----
243+ // These drive the core's own loops and are never sent by the shell, so they
244+ // are kept off the FFI surface entirely.
245+ //
246+ // The auth and poll results carry the *generation* of the round-trip that
247+ // produced them. An in-flight effect is never cancelled — it can only be
248+ // superseded — so without a generation a result belonging to an attempt we
249+ // already abandoned lands in the handler for the attempt that replaced it.
250+ // See `Model::poll_gen`.
251+ #[serde(skip)]
252+ #[facet(skip)]
253+ Authenticate,
254+ #[serde(skip)]
255+ #[facet(skip)]
256+ Poll,
257+ /// Fires `POLL_WATCHDOG_MS` after generation `n`'s poll is issued; a no-op
258+ /// unless that exact poll is somehow still in flight.
259+ #[serde(skip)]
260+ #[facet(skip)]
261+ PollWatchdog(u64),
262+ #[serde(skip)]
263+ #[facet(skip)]
264+ StartFlush,
265+ /// Fires `FLUSH_WATCHDOG_MS` after generation `n`'s flush is issued; a no-op
266+ /// unless that exact send is somehow still in flight — same pattern as
267+ /// `PollWatchdog`.
268+ #[serde(skip)]
269+ #[facet(skip)]
270+ FlushWatchdog(u64),
271+ /// Fires `AUTH_WATCHDOG_MS` after generation `n`'s auth round-trip is
272+ /// issued; a no-op unless that exact auth is somehow still in flight.
273+ #[serde(skip)]
274+ #[facet(skip)]
275+ AuthWatchdog(u64),
276+ #[serde(skip)]
277+ #[facet(skip)]
278+ ChallengeResult(u64, #[facet(opaque)] HttpResult),
279+ #[serde(skip)]
280+ #[facet(skip)]
281+ VerifyResult(u64, #[facet(opaque)] HttpResult),
282+ #[serde(skip)]
283+ #[facet(skip)]
284+ PollResult(u64, #[facet(opaque)] HttpResult),
285+ #[serde(skip)]
286+ #[facet(skip)]
287+ SendResult(u64, #[facet(opaque)] HttpResult),
288+}
289+
290+// ---------------------------------------------------------------------------
291+// Model
292+// ---------------------------------------------------------------------------
293+
294+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
295+pub enum ConnStatus {
296+ Offline,
297+ Connecting,
298+ Online,
299+}
300+
301+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
302+pub enum Page {
303+ /// The shell renders a spinner until `IdentityLoaded` arrives, so there is
304+ /// no distinct pre-identity page — conversations is the landing page.
305+ #[default]
306+ Conversations,
307+ Chat,
308+ AddContact,
309+ Settings,
310+ EditProfile,
311+}
312+
313+/// The six kv keys loaded at startup (`settings`, `profile`, `contacts`,
314+/// `cursor`, `outbox`, `seen_ids`). `Connect` waits for all of them.
315+const STARTUP_LOADS: u8 = 6;
316+
317+/// The batch a live `/messages` request is carrying: the first `count` items of
318+/// the outbox, all bound for `recipient`.
319+///
320+/// `SendResult` needs all three. The count is what it pops on success and what it
321+/// charges a retry against on a transient failure; the recipient is what tells
322+/// `SetBlocked` and `DeleteContact` whether the items they are about to remove
323+/// are the very ones this send is going to pop; and `bytes` — the size of the
324+/// body we actually built — is what a `413` halves to find a batch this relay
325+/// will accept (see `Model::send_batch_budget`).
326+struct InFlight {
327+ recipient: String,
328+ count: usize,
329+ bytes: usize,
330+}
331+
332+pub struct Model {
333+ /// Zeroized on drop: this is the Ed25519 seed, the one secret whose leak
334+ /// costs the user their identity.
335+ secret_key: Option<Zeroizing<Vec<u8>>>,
336+ my_pubkey: String,
337+ settings: Settings,
338+ profile: OwnProfile,
339+ contacts: BTreeMap<String, Contact>,
340+ messages: BTreeMap<String, Vec<StoredMessage>>,
341+ /// Q18: Per-peer dedup ID set, independent of the trimmed conversation.
342+ /// Without this, a replayed blob whose `id` was aged out by `trim_history`
343+ /// (past the 1000-message cap) passes the dedup check and re-enters the
344+ /// conversation. This set is loaded from and persisted to kv alongside
345+ /// messages, so it survives restarts.
346+ seen_ids: BTreeMap<String, HashSet<String>>,
347+ outbox: VecDeque<OutboxItem>,
348+ cursor: i64,
349+ token: Option<String>,
350+ conn: ConnStatus,
351+ poll_retries: u32,
352+ /// The send loop's own backoff counter, spent when a `429` arrives with no
353+ /// `Retry-After` to obey. Kept apart from `poll_retries` because the poll loop
354+ /// resets that one on every page it takes: a client polling happily while the
355+ /// relay rate-limits its sends would restart the send backoff at 3 s forever,
356+ /// which is not a backoff.
357+ send_retries: u32,
358+ /// A poll request is in flight. Guards against stacking concurrent long-poll
359+ /// loops (every extra loop re-polls itself forever and never terminates).
360+ polling: bool,
361+ /// Which poll the in-flight `PollResult` is allowed to belong to.
362+ ///
363+ /// Bumped every time a poll is issued, and every time one is *abandoned* (the
364+ /// watchdog gives up on it; a relay switch invalidates it). A `PollResult`
365+ /// whose generation no longer matches is dropped on the floor.
366+ ///
367+ /// A wall-clock "has it been long enough?" test cannot do this job. An HTTP
368+ /// effect stays pending across an iOS suspension while `now_ms()` runs on, so
369+ /// on resume *every* in-flight poll looks stuck — the watchdog abandons a
370+ /// request the shell is still about to resolve, and that resolution then
371+ /// clears the guard belonging to its replacement. Two poll loops, each
372+ /// re-polling itself forever. The generation is what makes abandoning safe.
373+ poll_gen: u64,
374+ /// An auth round-trip is in flight. `Connect` is fired from several places
375+ /// (startup, a 401, a reconnect timer, a server change); without this guard
376+ /// they stack, and each one that lands installs a token and starts its own
377+ /// poll loop.
378+ authenticating: bool,
379+ /// The same idea for the auth round-trip, which spans two legs (challenge →
380+ /// verify) and is restarted by `Connect` — which the shell fires on *every*
381+ /// return to the foreground (`SkrepkaApp.swift`). The challenge still in
382+ /// flight from before the app was backgrounded must not install a token or
383+ /// overwrite `conn` on top of the attempt that replaced it.
384+ auth_gen: u64,
385+ /// A `/messages` request is in flight. One at a time: `SendResult` pops the
386+ /// items it sent off the head of the outbox, so two overlapping sends would
387+ /// pop each other's messages.
388+ flushing: bool,
389+ /// Which flush the in-flight `SendResult` / `FlushWatchdog` is allowed to
390+ /// belong to. Same pattern as `poll_gen`: bumped every time a flush is
391+ /// issued and every time one is abandoned, so a `SendResult` or watchdog
392+ /// from a superseded send cannot clear the guard or pop items belonging to
393+ /// the replacement.
394+ flush_gen: u64,
395+ /// What the in-flight send will pop off the head of the outbox when it
396+ /// succeeds — see `InFlight`. `None` while nothing is in flight, and also
397+ /// while a send is in flight whose items have since been removed from under
398+ /// it (`SetBlocked`, `DeleteContact`): there is then nothing left to pop, and
399+ /// popping by count anyway would eat whichever messages slid into their place.
400+ in_flight: Option<InFlight>,
401+ /// What this relay will actually accept as a request body, in hex characters,
402+ /// once it has told us. `None` until then — meaning "assume
403+ /// `MAX_SEND_BATCH_BYTES`", which is a guess at a cap the protocol never
404+ /// publishes.
405+ ///
406+ /// A relay configured below our guess answers `413`, and a `413` read as a
407+ /// transient failure is a batch that is rebuilt identically, rejected
408+ /// identically, and retried until every item in it has burned its retry budget
409+ /// and been dropped — the outbox drains into the void without a single message
410+ /// leaving. So a `413` *measures* the cap instead: halve what we just tried and
411+ /// rebuild smaller. It is deliberately not reset on success — the batch that
412+ /// worked is evidence about the relay, and the relay's cap does not grow — only
413+ /// on a server change, where the evidence no longer applies.
414+ send_batch_budget: Option<usize>,
415+ /// Startup kv loads still outstanding. `Connect` only fires at 0, so a poll
416+ /// can't land and be persisted against a half-loaded model — which would
417+ /// then be clobbered by the loads still in flight.
418+ loads_pending: u8,
419+ /// A startup kv *read* failed — as opposed to finding the key absent.
420+ ///
421+ /// The distinction is the whole point of `parse_kv`'s `Result<Option<T>, ()>`:
422+ /// an absent key is a fresh install and defaulting is right, but a failed read
423+ /// leaves a hole in the model over data that is still on disk, and writing that
424+ /// hole back destroys it. The outbox is the sharpest case — `ingest_poll`
425+ /// persists it on *every* poll page, so a single failed read followed by one
426+ /// empty poll writes `[]` over a queue of real, unsent messages.
427+ ///
428+ /// So this latches the model as untrustworthy, and every write path checks it:
429+ /// no persist, no ingest, no flush. Nothing is lost, because nothing is
430+ /// written — the data stays on disk for the next launch, which re-runs the
431+ /// loads (`IdentityLoaded` fires on every boot) and clears the flag if they
432+ /// succeed. It is deliberately one flag and not a per-key set: a read that
433+ /// fails because the device is locked fails for every key, and a model that is
434+ /// wrong about its outbox is not one to be trusted about its contacts either.
435+ kv_load_failed: bool,
436+ /// `now_ms()` before which `flush_next` must not send.
437+ ///
438+ /// A `429` schedules a `StartFlush` for the far side of the relay's
439+ /// `Retry-After`, but that timer is not the only thing that fires one — a poll
440+ /// page and a fresh `SendText` both do. Without a pause the wait is decorative:
441+ /// the next poll page walks straight through it and hammers the relay that just
442+ /// asked us to stop.
443+ flush_paused_until: i64,
444+ page: Page,
445+ active_peer: Option<String>,
446+ compose: String,
447+ error: Option<String>,
448+}
449+
450+impl Default for Model {
451+ fn default() -> Self {
452+ Model {
453+ secret_key: None,
454+ my_pubkey: String::new(),
455+ settings: Settings::default(),
456+ profile: OwnProfile::default(),
457+ contacts: BTreeMap::new(),
458+ messages: BTreeMap::new(),
459+ seen_ids: BTreeMap::new(),
460+ outbox: VecDeque::new(),
461+ cursor: 0,
462+ token: None,
463+ conn: ConnStatus::Offline,
464+ poll_retries: 0,
465+ send_retries: 0,
466+ polling: false,
467+ poll_gen: 0,
468+ authenticating: false,
469+ auth_gen: 0,
470+ flushing: false,
471+ flush_gen: 0,
472+ in_flight: None,
473+ send_batch_budget: None,
474+ loads_pending: 0,
475+ kv_load_failed: false,
476+ flush_paused_until: 0,
477+ page: Page::default(),
478+ active_peer: None,
479+ compose: String::new(),
480+ error: None,
481+ }
482+ }
483+}
484+
485+impl Model {
486+ fn identity(&self) -> Option<Identity> {
487+ self.secret_key
488+ .as_ref()
489+ .and_then(|sk| Identity::from_secret_bytes(sk).ok())
490+ }
491+
492+ /// Tick off one startup load. Returns `true` exactly once — when the last of
493+ /// the five lands — so `Connect` is emitted a single time.
494+ fn startup_load_done(&mut self) -> bool {
495+ if self.loads_pending == 0 {
496+ return false;
497+ }
498+ self.loads_pending -= 1;
499+ self.loads_pending == 0
500+ }
501+
502+ /// Open a new poll generation and return it. Any `PollResult` still in flight
503+ /// under the old generation is now dead to us.
504+ fn next_poll_gen(&mut self) -> u64 {
505+ self.poll_gen = self.poll_gen.wrapping_add(1);
506+ self.poll_gen
507+ }
508+
509+ /// Abandon the in-flight poll, if any: release the loop guard and retire the
510+ /// generation so a late `PollResult` cannot clear the *replacement* poll's
511+ /// guard or fold a stale page (and a stale cursor) into the model.
512+ fn abandon_poll(&mut self) {
513+ self.polling = false;
514+ self.poll_gen = self.poll_gen.wrapping_add(1);
515+ }
516+
517+ /// Open a new auth generation and return it.
518+ fn next_auth_gen(&mut self) -> u64 {
519+ self.auth_gen = self.auth_gen.wrapping_add(1);
520+ self.auth_gen
521+ }
522+
523+ /// Open a new flush generation and return it. Same pattern as
524+ /// `next_poll_gen`: a `SendResult` or `FlushWatchdog` whose generation no
525+ /// longer matches is dropped, so a late result from a superseded send
526+ /// cannot clear the guard or pop items belonging to the replacement.
527+ fn next_flush_gen(&mut self) -> u64 {
528+ self.flush_gen = self.flush_gen.wrapping_add(1);
529+ self.flush_gen
530+ }
531+
532+ /// Abandon the in-flight flush, if any: release the guard and retire the
533+ /// generation so a late `SendResult` cannot clear the *replacement* flush's
534+ /// guard or pop its items.
535+ fn abandon_flush(&mut self) {
536+ self.flushing = false;
537+ self.in_flight = None;
538+ self.flush_gen = self.flush_gen.wrapping_add(1);
539+ }
540+
541+ /// Abandon the in-flight auth round-trip (either leg).
542+ fn abandon_auth(&mut self) {
543+ self.authenticating = false;
544+ self.auth_gen = self.auth_gen.wrapping_add(1);
545+ }
546+
547+ /// Every item bound for `peer` is about to be removed from the outbox. If the
548+ /// send in flight is the one carrying them, forget what it was going to pop:
549+ /// those items will not be there when it lands, and popping by count anyway
550+ /// would take whatever slid into their place — another peer's mail.
551+ ///
552+ /// The `flushing` guard stays up. Nothing can cancel the request; it is still
553+ /// out there, and a second send started underneath it would have its own items
554+ /// popped by the first one's result.
555+ fn detach_in_flight(&mut self, peer: &str) {
556+ if self.in_flight.as_ref().is_some_and(|f| f.recipient == peer) {
557+ self.in_flight = None;
558+ }
559+ }
560+
561+ /// How many items at the head of the outbox are already on the wire, and so
562+ /// must not be rewritten in place (their ciphertext is built and gone).
563+ fn in_flight_count(&self) -> usize {
564+ self.in_flight.as_ref().map_or(0, |f| f.count)
565+ }
566+}
567+
568+// ---------------------------------------------------------------------------
569+// Helpers (pure)
570+// ---------------------------------------------------------------------------
571+
572+/// Wall-clock milliseconds.
573+///
574+/// This reads the system clock from inside `update()`, which is supposed to be a
575+/// pure function of `(Event, Model)`. It isn't, and that has real costs: the
576+/// state machine can't be replayed deterministically, and tests can't pin "now".
577+/// The clean fix is to take the time as a `crux_time` effect and feed it back in
578+/// as an event — but every timestamped path (`SendText`, `SaveProfile`,
579+/// `ingest_poll`) would have to become a two-step round-trip through the shell.
580+/// That refactor is worth doing; it is not worth folding into a bug-fix pass, so
581+/// the impurity is documented rather than hidden.
582+// TODO: replace with crux_time effect for deterministic timestamps
583+fn now_ms() -> i64 {
584+ SystemTime::now()
585+ .duration_since(UNIX_EPOCH)
586+ .map(|d| d.as_millis() as i64)
587+ .unwrap_or(1)
588+}
589+
590+/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
591+///
592+/// This feeds the auth signature, which is bound to the host (PROTOCOL.md §6),
593+/// so it must agree with what the relay itself computes. Splitting on `:` by hand
594+/// mangles an IPv6 literal (`http://[::1]:8080` → `[`), producing a signature the
595+/// server rejects; `Url` gets the bracket rules right.
596+fn server_host(url: &str) -> String {
597+ Url::parse(url)
598+ .ok()
599+ .and_then(|u| u.host_str().map(str::to_string))
600+ .map(|h| h.trim_end_matches('.').to_lowercase())
601+ .unwrap_or_default()
602+}
603+
604+/// Validate and canonicalize a user-entered relay URL.
605+///
606+/// The shell hands us raw text-field contents and `crux_http` unwraps the parse,
607+/// so anything the real parser rejects must be rejected *here* or it panics the
608+/// core. That means validating with the same parser rather than a lookalike: the
609+/// hand-rolled check this replaces accepted `http://[bad` and `http://ho^st`,
610+/// both of which `Url::parse` refuses.
611+///
612+/// A query or fragment is rejected too. Every request appends a path
613+/// (`{url}/poll`), so `http://x?y` would yield `http://x?y/poll` — the path
614+/// swallowed into the query string, pointing at nothing.
615+///
616+/// Returns the normalized URL (no trailing slash) or `None`.
617+fn normalize_server_url(input: &str) -> Option<String> {
618+ let url = Url::parse(input.trim()).ok()?;
619+ if !matches!(url.scheme(), "http" | "https") {
620+ return None;
621+ }
622+ if url.host_str().is_none_or(str::is_empty) {
623+ return None;
624+ }
625+ if url.query().is_some() || url.fragment().is_some() {
626+ return None;
627+ }
628+ Some(url.as_str().trim_end_matches('/').to_string())
629+}
630+
631+fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
632+ serde_json::to_vec(value).unwrap_or_default()
633+}
634+
635+// ---------------------------------------------------------------------------
636+// Wire request/response shapes
637+// ---------------------------------------------------------------------------
638+
639+#[derive(Serialize)]
640+struct ChallengeReq<'a> {
641+ pubkey: &'a str,
642+}
643+#[derive(Deserialize)]
644+struct ChallengeResp {
645+ challenge: String,
646+}
647+#[derive(Serialize)]
648+struct VerifyReq<'a> {
649+ pubkey: &'a str,
650+ challenge: &'a str,
651+ signature: &'a str,
652+ #[serde(rename = "revokeOthers")]
653+ revoke_others: bool,
654+}
655+#[derive(Deserialize)]
656+struct VerifyResp {
657+ token: String,
658+}
659+#[derive(Serialize)]
660+struct PollReq {
661+ cursor: i64,
662+}
663+#[derive(Deserialize, Default)]
664+struct PollResp {
665+ #[serde(default)]
666+ events: Vec<PollEvent>,
667+ #[serde(default)]
668+ cursor: i64,
669+}
670+#[derive(Deserialize)]
671+struct PollEvent {
672+ #[serde(rename = "encryptedBlob")]
673+ encrypted_blob: String,
674+}
675+
676+/// Q17: Deferred delivery-ack accumulator. Collects ack_ids and the max `ts`
677+/// across all `delivery.ack` payloads from one sender in a single poll page,
678+/// so the staleness check and watermark update run once per sender after the
679+/// event loop — immune to relay page ordering.
680+#[derive(Default)]
681+struct PendingAck {
682+ ts: i64,
683+ ids: HashSet<String>,
684+}
685+#[derive(Serialize)]
686+struct SendBatch {
687+ messages: Vec<Envelope>,
688+}
689+
690+// ---------------------------------------------------------------------------
691+// App
692+// ---------------------------------------------------------------------------
693+
694+#[derive(Default)]
695+pub struct Skrepka;
696+
697+impl App for Skrepka {
698+ type Event = Event;
699+ type Model = Model;
700+ type ViewModel = ViewModel;
701+ type Effect = Effect;
702+
703+ #[allow(clippy::too_many_lines)]
704+ fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
705+ match event {
706+ // ---------------- lifecycle ----------------
707+ Event::IdentityLoaded(sk) => {
708+ let Ok(id) = Identity::from_secret_bytes(&sk) else {
709+ model.error = Some("invalid identity key".into());
710+ return render();
711+ };
712+ model.secret_key = Some(Zeroizing::new(sk));
713+ model.my_pubkey = id.public_key_hex();
714+ model.page = Page::Conversations;
715+ // Fan out the startup loads. `Connect` waits until the last of
716+ // them lands (see `startup_load_done`).
717+ model.loads_pending = STARTUP_LOADS;
718+ // A fresh round of loads: whatever a previous one failed to read,
719+ // this one gets to answer for. Clearing it here is what lets a
720+ // relaunch recover from a locked-device read failure.
721+ model.kv_load_failed = false;
722+ Command::all([
723+ KeyValue::get(K_SETTINGS).then_send(Event::LoadedSettings),
724+ KeyValue::get(K_PROFILE).then_send(Event::LoadedProfile),
725+ KeyValue::get(K_CONTACTS).then_send(Event::LoadedContacts),
726+ KeyValue::get(K_CURSOR).then_send(Event::LoadedCursor),
727+ KeyValue::get(K_OUTBOX).then_send(Event::LoadedOutbox),
728+ KeyValue::get(K_SEEN_IDS).then_send(Event::LoadedSeenIds),
729+ ])
730+ .and(render())
731+ }
732+
733+ Event::LoadedSettings(res) => {
734+ match parse_kv::<Settings>(res) {
735+ Ok(Some(s)) => {
736+ // Re-validate on the way in. The URL was normalized when the
737+ // user typed it, but the blob on disk is not trustworthy: an
738+ // older build wrote URLs this parser rejects, and a corrupted
739+ // or hand-edited file would reach `Http::post`, which unwraps
740+ // the parse. That is a panic on every launch — a boot loop
741+ // with no way out from inside the app.
742+ model.settings.server_url = normalize_server_url(&s.server_url)
743+ .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
744+ }
745+ Ok(None) => {}
746+ Err(()) => {
747+ model.kv_load_failed = true;
748+ model.error = Some("could not read settings from storage".into());
749+ }
750+ }
751+ self.startup_done(model).and(render())
752+ }
753+ Event::LoadedProfile(res) => {
754+ match parse_kv::<OwnProfile>(res) {
755+ Ok(Some(p)) => model.profile = p,
756+ Ok(None) => {}
757+ Err(()) => {
758+ model.kv_load_failed = true;
759+ model.error = Some("could not read profile from storage".into());
760+ }
761+ }
762+ self.startup_done(model).and(render())
763+ }
764+ Event::LoadedContacts(res) => {
765+ let mut cmds = vec![self.startup_done(model)];
766+ match parse_kv::<Vec<Contact>>(res) {
767+ Ok(Some(list)) => {
768+ model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
769+ // Lazily load each conversation's messages.
770+ cmds.extend(model.contacts.keys().map(|peer| {
771+ let peer = peer.clone();
772+ KeyValue::get(k_messages(&peer))
773+ .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
774+ }));
775+ }
776+ Ok(None) => {}
777+ Err(()) => {
778+ model.kv_load_failed = true;
779+ model.error = Some("could not read contacts from storage".into());
780+ }
781+ }
782+ Command::all(cmds).and(render())
783+ }
784+ Event::LoadedCursor(res) => {
785+ match parse_kv::<i64>(res) {
786+ Ok(Some(c)) => model.cursor = c,
787+ Ok(None) => {}
788+ Err(()) => {
789+ model.kv_load_failed = true;
790+ model.error = Some("could not read cursor from storage".into());
791+ }
792+ }
793+ self.startup_done(model).and(render())
794+ }
795+ Event::LoadedOutbox(res) => {
796+ match parse_kv::<Vec<OutboxItem>>(res) {
797+ Ok(Some(list)) => model.outbox = list.into(),
798+ Ok(None) => {}
799+ Err(()) => {
800+ model.kv_load_failed = true;
801+ model.error = Some("could not read outbox from storage".into());
802+ }
803+ }
804+ self.startup_done(model).and(render())
805+ }
806+ Event::LoadedSeenIds(res) => {
807+ match parse_kv::<BTreeMap<String, HashSet<String>>>(res) {
808+ Ok(Some(map)) => model.seen_ids = map,
809+ Ok(None) => {}
810+ Err(()) => {
811+ // Non-fatal: dedup falls back to the conversation-derived
812+ // set, which is weaker but functional.
813+ model.seen_ids = BTreeMap::new();
814+ }
815+ }
816+ self.startup_done(model).and(render())
817+ }
818+ Event::LoadedMessages(peer, res) => {
819+ match parse_kv::<Vec<StoredMessage>>(res) {
820+ Ok(Some(list)) => {
821+ // D5: Add on-disk message IDs to seen_ids so future
822+ // replays don't pass dedup. Without this, a seen_ids
823+ // kv load failure leaves the set empty, and on-disk
824+ // messages aren't protected against replay.
825+ if let Some(id_set) = model.seen_ids.get_mut(&peer) {
826+ id_set.extend(list.iter().map(|m| m.id.clone()));
827+ }
828+ // Merge: a poll that beat this lazy load may have already
829+ // inserted messages for this peer. Overwriting with the
830+ // on-disk list (which the GET read before the poll wrote)
831+ // would silently destroy the freshly-ingested messages.
832+ // Union by id, keeping both the on-disk history and any
833+ // in-memory additions.
834+ match model.messages.get_mut(&peer) {
835+ Some(existing) => {
836+ let existing_ids: HashSet<&String> =
837+ existing.iter().map(|m| &m.id).collect();
838+ let new_msgs: Vec<StoredMessage> = list
839+ .into_iter()
840+ .filter(|m| !existing_ids.contains(&m.id))
841+ .collect();
842+ existing.extend(new_msgs);
843+ // Re-sort by ts to maintain the invariant.
844+ existing.sort_by_key(|m| m.ts);
845+ trim_history(existing);
846+ }
847+ None => {
848+ model.messages.insert(peer, list);
849+ }
850+ }
851+ }
852+ Ok(None) => {}
853+ Err(()) => {
854+ // Leaving the conversation absent is not enough on its own.
855+ // An absent conversation is an *empty* one to everything
856+ // downstream, so the next message from this peer would be
857+ // appended to nothing and `persist_messages` would write
858+ // that one message over the whole history still on disk.
859+ // Latch the model as untrustworthy instead — same reasoning
860+ // as the outbox, same fix.
861+ model.kv_load_failed = true;
862+ model.error = Some("could not read messages from storage".into());
863+ }
864+ }
865+ render()
866+ }
867+ Event::Saved(res) => {
868+ // A dropped write silently loses data (a message, a contact, the
869+ // cursor), so surface it rather than swallowing the result.
870+ if let Err(e) = res {
871+ model.error = Some(format!("could not save data: {e}"));
872+ return render();
873+ }
874+ Command::done()
875+ }
876+ Event::SavedCursor(res) => {
877+ // The cursor write is what tells the relay it may delete the mail
878+ // we just took (it acks up to `cursor` on the next poll). Only
879+ // re-poll once it has actually landed: if we polled in parallel
880+ // and the write failed, the messages would be gone from the
881+ // server and absent from disk.
882+ if let Err(e) = res {
883+ model.error = Some(format!("could not save data: {e}"));
884+ return self.backoff_poll(model);
885+ }
886+ Command::event(Event::Poll)
887+ }
888+
889+ // ---------------- navigation ----------------
890+ // `error` is about the thing the user was just doing. Leaving it set
891+ // across a page change strands a stale message ("invalid public key")
892+ // on an unrelated screen, so every navigation clears it.
893+ Event::ShowConversations => {
894+ model.page = Page::Conversations;
895+ model.active_peer = None;
896+ model.error = None;
897+ render()
898+ }
899+ Event::ShowSettings => {
900+ model.page = Page::Settings;
901+ model.error = None;
902+ render()
903+ }
904+ Event::ShowAddContact => {
905+ model.page = Page::AddContact;
906+ model.error = None;
907+ render()
908+ }
909+ Event::ShowEditProfile => {
910+ model.page = Page::EditProfile;
911+ model.error = None;
912+ render()
913+ }
914+ Event::OpenChat(peer) => {
915+ model.active_peer = Some(peer);
916+ model.page = Page::Chat;
917+ model.error = None;
918+ render()
919+ }
920+ Event::Back => {
921+ model.page = Page::Conversations;
922+ model.active_peer = None;
923+ model.error = None;
924+ render()
925+ }
926+
927+ // ---------------- user actions ----------------
928+ Event::ComposeChanged(s) => {
929+ model.compose = s;
930+ render()
931+ }
932+ Event::SetServerUrl(url) => {
933+ let Some(url) = normalize_server_url(&url) else {
934+ model.error = Some("server URL must be an http(s) address".into());
935+ return render();
936+ };
937+ model.error = None;
938+ model.settings.server_url = url;
939+ model.token = None;
940+ model.conn = ConnStatus::Offline;
941+ // Whatever we learned about the old relay's body cap was about *it*.
942+ // A new relay is entitled to a bigger one, and carrying a shrunken
943+ // budget over would cap our batches against a limit that no longer
944+ // exists — with no `413` left to correct it, since the new relay has
945+ // no reason to send one.
946+ model.send_batch_budget = None;
947+ // The cursor is *per relay*: it is that server's own sequence
948+ // number, and the next poll acks everything up to it. Carrying
949+ // relay A's cursor (roughly `now_ms()`) over to relay B tells B
950+ // to delete every message queued for us before it hands any of
951+ // them over. Start B from the beginning.
952+ model.cursor = 0;
953+ // ...and retire the poll that is still in flight *to relay A*, or
954+ // resetting the cursor achieves nothing: A's answer is already on
955+ // its way, it carries A's cursor, and `ingest_poll` would write it
956+ // straight back over the zero we just set. The next poll — to B —
957+ // would then ack a sequence number B never issued, and B would
958+ // drop every message waiting for us.
959+ model.abandon_poll();
960+ // ...and retire the send that is still in flight *to relay A*,
961+ // for the same reason: its `SendResult` carries A's acceptance,
962+ // and popping items here would eat whatever the new relay's
963+ // send is about to carry. The generation check in `SendResult`
964+ // is what makes the late result a no-op.
965+ model.abandon_flush();
966+ // The old relay's rate limit was the old relay's opinion. A new one is
967+ // entitled to take our sends immediately, and carrying the pause over
968+ // would park the outbox against a limit that no longer applies.
969+ model.flush_paused_until = 0;
970+ model.send_retries = 0;
971+ // D2: Reset poll retries too — the new relay's poll failures
972+ // should start fresh, not inherit the old relay's backoff.
973+ model.poll_retries = 0;
974+ let writes = if self.refuse_write(model) {
975+ Command::done()
976+ } else {
977+ Command::all([
978+ KeyValue::set(K_SETTINGS, json_bytes(&model.settings))
979+ .then_send(Event::Saved),
980+ KeyValue::set(K_CURSOR, json_bytes(&0i64)).then_send(Event::Saved),
981+ ])
982+ };
983+ writes.and(Command::event(Event::Connect)).and(render())
984+ }
985+ Event::Connect => {
986+ // Connect restarts the auth flow, so whatever challenge/verify was
987+ // in flight is now stale and its guard must come off. Retiring the
988+ // generation as we do it is what makes that safe: the shell fires
989+ // Connect on every return to the foreground, so the round-trip we
990+ // are dropping here is usually one that is still very much alive
991+ // and about to resolve. Clearing the guard alone would let it land
992+ // and install its token on top of the attempt replacing it.
993+ model.abandon_auth();
994+ // The shell fires `Connect` on every return to the foreground. A model
995+ // that failed a startup read must not take that invitation: connecting
996+ // leads to polling, and polling into a hole acks mail to the relay that
997+ // we cannot store. Staying offline is what keeps the disk intact until
998+ // a relaunch re-runs the loads.
999+ if model.kv_load_failed {
1000+ return render();
1001+ }
1002+ if model.secret_key.is_some() && model.conn != ConnStatus::Online {
1003+ Command::event(Event::Authenticate)
1004+ } else if model.secret_key.is_some()
1005+ && model.conn == ConnStatus::Online
1006+ && !model.polling
1007+ {
1008+ // The poll loop can die without `conn` going offline: after
1009+ // `PollResult` clears `polling`, the re-poll is chained off
1010+ // `SavedCursor`. If the shell drops that effect (backgrounded
1011+ // app, bug), `polling` stays `false` with no pending re-poll
1012+ // and the watchdog sees `!polling` and does nothing. The
1013+ // status shows "online" but no polling ever happens. Restart
1014+ // the loop here — `Connect` is fired on every foreground return.
1015+ Command::event(Event::Poll)
1016+ } else {
1017+ render()
1018+ }
1019+ }
1020+ Event::AddContact { input, nickname } => {
1021+ // Clear a stale error from a previous failed add.
1022+ model.error = None;
1023+ // A nickname is stored in the contacts kv blob, which is
1024+ // rewritten in full on every change. An unbounded nickname
1025+ // bloats the blob and every subsequent write.
1026+ if nickname.len() > protocol::MAX_DISPLAY_NAME_LEN {
1027+ model.error = Some("nickname too long".into());
1028+ return render();
1029+ }
1030+ match phonemic::try_parse_pubkey(&input) {
1031+ Some(hex) if hex != model.my_pubkey => {
1032+ model
1033+ .contacts
1034+ .entry(hex.clone())
1035+ .or_insert_with(|| Contact::new(hex.clone(), nickname.clone(), now_ms()));
1036+ if !nickname.is_empty() {
1037+ if let Some(c) = model.contacts.get_mut(&hex) {
1038+ c.nickname = nickname;
1039+ }
1040+ }
1041+ model.error = None;
1042+ model.page = Page::Conversations;
1043+ self.persist_contacts(model).and(render())
1044+ }
1045+ Some(_) => {
1046+ model.error = Some("that's your own key".into());
1047+ render()
1048+ }
1049+ None => {
1050+ model.error = Some("invalid public key".into());
1051+ render()
1052+ }
1053+ }
1054+ }
1055+ Event::SetBlocked { peer, blocked } => {
1056+ if let Some(c) = model.contacts.get_mut(&peer) {
1057+ c.blocked = blocked;
1058+ }
1059+ // Clear a stale error (e.g. "cannot send to a blocked contact")
1060+ // when the user unblocks — the block that caused it is now lifted.
1061+ model.error = None;
1062+ if blocked {
1063+ // Gating *ingest* on the block only stops the acks we have not
1064+ // queued yet. Anything already sitting in the outbox for this
1065+ // peer — an ack for the very message that made the user block
1066+ // them, a profile from the last broadcast — still flushes, and
1067+ // an ack is precisely the "we are online and reading you"
1068+ // signal the block is supposed to cut off. Drop it all: once
1069+ // blocked, nothing more goes to this peer.
1070+ model.outbox.retain(|item| item.recipient != peer);
1071+ model.detach_in_flight(&peer);
1072+ return Command::all([self.persist_contacts(model), self.persist_outbox(model)])
1073+ .and(render());
1074+ }
1075+ self.persist_contacts(model).and(render())
1076+ }
1077+ Event::DeleteContact { peer } => {
1078+ model.contacts.remove(&peer);
1079+ model.messages.remove(&peer);
1080+ model.seen_ids.remove(&peer);
1081+ model.outbox.retain(|item| item.recipient != peer);
1082+ // The send in flight may be carrying the very items we just
1083+ // dropped; if so its result must pop nothing.
1084+ model.detach_in_flight(&peer);
1085+ if model.active_peer.as_deref() == Some(peer.as_str()) {
1086+ model.active_peer = None;
1087+ model.page = Page::Conversations;
1088+ }
1089+ model.error = None;
1090+ Command::all([
1091+ self.persist_contacts(model),
1092+ // The conversation is gone from the model, but `persist_messages`
1093+ // would leave a `messages:<peer>` blob behind holding every
1094+ // message the user just asked us to forget. Delete the key.
1095+ KeyValue::delete(k_messages(&peer)).then_send(Event::Saved),
1096+ self.persist_outbox(model),
1097+ // Q18: persist the seen_ids map without the removed peer.
1098+ self.persist_seen_ids(model),
1099+ ])
1100+ .and(render())
1101+ }
1102+ Event::SaveProfile {
1103+ display_name,
1104+ bio,
1105+ photo,
1106+ } => {
1107+ // Clear a stale error from a previous failed save.
1108+ model.error = None;
1109+ // The recipient's `parse_payload` enforces these caps and
1110+ // silently drops oversized profiles. Without these checks the
1111+ // sender's local profile would be saved, broadcast to every
1112+ // contact, and silently dropped by every recipient — a silent
1113+ // divergence between what the sender sees and what contacts see.
1114+ if display_name.chars().count() > protocol::MAX_DISPLAY_NAME_LEN {
1115+ model.error = Some("display name too long".into());
1116+ return render();
1117+ }
1118+ if bio.chars().count() > protocol::MAX_BIO_LEN {
1119+ model.error = Some("bio too long".into());
1120+ return render();
1121+ }
1122+ if photo.as_ref().is_some_and(|p| p.len() > protocol::MAX_PHOTO_LEN) {
1123+ model.error = Some("photo too large".into());
1124+ return render();
1125+ }
1126+ // S6: Validate the photo is valid base64 before broadcasting —
1127+ // the recipient's parse_payload rejects invalid base64, so an
1128+ // invalid photo would be silently dropped by every recipient.
1129+ if photo.as_ref().is_some_and(|p| BASE64_STANDARD.decode(p).is_err()) {
1130+ model.error = Some("photo is not valid base64".into());
1131+ return render();
1132+ }
1133+ model.profile = OwnProfile {
1134+ display_name,
1135+ bio,
1136+ photo,
1137+ };
1138+ model.page = Page::Conversations;
1139+ let ts = now_ms();
1140+ let payload = Payload::Profile {
1141+ display_name: Some(model.profile.display_name.clone()),
1142+ bio: Some(model.profile.bio.clone()),
1143+ photo: model.profile.photo.clone(),
1144+ };
1145+ // Broadcast the new profile to every contact we haven't blocked.
1146+ // Blocking is meant to cut the peer off in both directions; a
1147+ // profile broadcast would keep feeding them our display name,
1148+ // avatar, and a liveness signal.
1149+ //
1150+ // One `Arc<String>` for the whole fan-out: the payload carries a
1151+ // base64 photo, and a copy per contact would be megabytes in memory
1152+ // for a list of any size. (The `outbox` kv blob still serializes the
1153+ // string once per item — serde has no idea the `Arc`s are shared —
1154+ // but the supersede-in-place below caps that at one pending profile
1155+ // per contact, which is the bound that actually matters.)
1156+ let recipients: Vec<String> = model
1157+ .contacts
1158+ .values()
1159+ .filter(|c| !c.blocked)
1160+ .map(|c| c.pubkey.clone())
1161+ .collect();
1162+ let payload_json = Arc::new(protocol::serialize_payload(&payload, ts));
1163+ // Items already on the wire cannot be rewritten: their ciphertext is
1164+ // built and gone, and `SendResult` will pop them on success — so a
1165+ // payload swapped into one now would be dropped having never been
1166+ // sent. Supersede only what is still behind them.
1167+ let live = model.in_flight_count();
1168+ for peer in recipients {
1169+ // A profile is state, not an event: only the newest one means
1170+ // anything. Overwrite the pending one for this peer instead of
1171+ // queueing a second — five quick edits to a profile with a photo
1172+ // would otherwise be five payloads per contact, each of which we
1173+ // then encrypt and send.
1174+ match model
1175+ .outbox
1176+ .iter_mut()
1177+ .skip(live)
1178+ .find(|i| i.is_profile() && i.recipient == peer)
1179+ {
1180+ Some(pending) => pending.envelope_json = Arc::clone(&payload_json),
1181+ None => model
1182+ .outbox
1183+ .push_back(OutboxItem::profile(peer, Arc::clone(&payload_json))),
1184+ }
1185+ }
1186+ Command::all([
1187+ self.persist_profile(model),
1188+ self.persist_outbox(model),
1189+ ])
1190+ .and(Command::event(Event::StartFlush))
1191+ .and(render())
1192+ }
1193+ Event::SendText => self.send_text(model),
1194+
1195+ // ---------------- auth ----------------
1196+ Event::Authenticate => {
1197+ // Exactly one auth round-trip at a time. `Authenticate` is reached
1198+ // from startup, a 401 on poll, a 401 on send, a reconnect timer
1199+ // and a server change — all of which can fire at once after a
1200+ // network blip. Each one that got through would hit
1201+ // `/auth/challenge`, and each token that came back would install
1202+ // itself and kick off another poll loop.
1203+ if model.authenticating {
1204+ return Command::done();
1205+ }
1206+ let Some(id) = model.identity() else {
1207+ return render();
1208+ };
1209+ model.conn = ConnStatus::Connecting;
1210+ model.error = None;
1211+ let url = format!("{}/auth/challenge", model.settings.server_url);
1212+ let body = ChallengeReq {
1213+ pubkey: &id.public_key_hex(),
1214+ };
1215+ match Http::post(url).body_json(&body) {
1216+ Ok(req) => {
1217+ model.authenticating = true;
1218+ let gen = model.next_auth_gen();
1219+ req.build()
1220+ .then_send(move |r| Event::ChallengeResult(gen, r))
1221+ .and(
1222+ Time::notify_after(Duration::from_millis(AUTH_WATCHDOG_MS))
1223+ .0
1224+ .then_send(move |_| Event::AuthWatchdog(gen)),
1225+ )
1226+ .and(render())
1227+ }
1228+ Err(_) => {
1229+ model.conn = ConnStatus::Offline;
1230+ model.error = Some("could not build auth request".into());
1231+ self.schedule_reconnect(model)
1232+ }
1233+ }
1234+ }
1235+ Event::ChallengeResult(gen, res) => {
1236+ // A challenge from an attempt we already gave up on (a relay
1237+ // switch, a foreground `Connect`). Its `authenticating` is not
1238+ // ours to clear and its challenge is not ours to sign.
1239+ if gen != model.auth_gen {
1240+ return Command::done();
1241+ }
1242+ // Cleared here and re-set only if the verify leg actually goes
1243+ // out — every exit from this arm must leave the guard consistent.
1244+ model.authenticating = false;
1245+ let Ok(mut resp) = res else {
1246+ model.conn = ConnStatus::Offline;
1247+ return self.schedule_reconnect(model);
1248+ };
1249+ let bytes = resp.take_body().unwrap_or_default();
1250+ let Some(id) = model.identity() else {
1251+ return render();
1252+ };
1253+ let Ok(c) = serde_json::from_slice::<ChallengeResp>(&bytes) else {
1254+ model.conn = ConnStatus::Offline;
1255+ return self.schedule_reconnect(model);
1256+ };
1257+ let host = server_host(&model.settings.server_url);
1258+ let Ok(signature) = id.sign_challenge(&host, &c.challenge) else {
1259+ // An over-long challenge: a relay trying to get us to sign
1260+ // something of its choosing. Back off rather than comply.
1261+ model.conn = ConnStatus::Offline;
1262+ model.error = Some("relay sent a malformed auth challenge".into());
1263+ return self.schedule_reconnect(model);
1264+ };
1265+ let url = format!("{}/auth/verify", model.settings.server_url);
1266+ let body = VerifyReq {
1267+ pubkey: &id.public_key_hex(),
1268+ challenge: &c.challenge,
1269+ signature: &signature,
1270+ revoke_others: false,
1271+ };
1272+ match Http::post(url).body_json(&body) {
1273+ Ok(req) => {
1274+ model.authenticating = true;
1275+ // The verify leg belongs to the same attempt as the
1276+ // challenge that produced it, so it keeps the generation
1277+ // rather than opening a new one.
1278+ req.build().then_send(move |r| Event::VerifyResult(gen, r))
1279+ }
1280+ Err(_) => {
1281+ model.conn = ConnStatus::Offline;
1282+ model.error = Some("could not build auth request".into());
1283+ self.schedule_reconnect(model)
1284+ }
1285+ }
1286+ }
1287+ Event::VerifyResult(gen, res) => {
1288+ if gen != model.auth_gen {
1289+ return Command::done();
1290+ }
1291+ model.authenticating = false;
1292+ let Ok(mut resp) = res else {
1293+ model.conn = ConnStatus::Offline;
1294+ return self.schedule_reconnect(model);
1295+ };
1296+ let bytes = resp.take_body().unwrap_or_default();
1297+ match serde_json::from_slice::<VerifyResp>(&bytes) {
1298+ Ok(v) if !v.token.is_empty() => {
1299+ model.token = Some(v.token);
1300+ model.conn = ConnStatus::Online;
1301+ model.poll_retries = 0;
1302+ model.error = None;
1303+ Command::event(Event::Poll)
1304+ .and(Command::event(Event::StartFlush))
1305+ .and(render())
1306+ }
1307+ _ => {
1308+ model.conn = ConnStatus::Offline;
1309+ self.schedule_reconnect(model)
1310+ }
1311+ }
1312+ }
1313+
1314+ // ---------------- poll ----------------
1315+ Event::Poll => {
1316+ // Exactly one poll loop at a time: each PollResult re-issues Poll,
1317+ // so a second concurrent loop would double forever.
1318+ if model.polling {
1319+ return Command::done();
1320+ }
1321+ let Some(token) = model.token.clone() else {
1322+ return Command::event(Event::Authenticate);
1323+ };
1324+ let url = format!("{}/poll", model.settings.server_url);
1325+ let body = PollReq {
1326+ cursor: model.cursor,
1327+ };
1328+ match Http::post(url)
1329+ .header("authorization", format!("Bearer {token}"))
1330+ .body_json(&body)
1331+ {
1332+ Ok(req) => {
1333+ model.polling = true;
1334+ let gen = model.next_poll_gen();
1335+ // Arm the watchdog alongside the request: if this poll
1336+ // never resolves, nothing else will ever clear `polling`.
1337+ req.build()
1338+ .then_send(move |r| Event::PollResult(gen, r))
1339+ .and(
1340+ Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1341+ .0
1342+ .then_send(move |_| Event::PollWatchdog(gen)),
1343+ )
1344+ }
1345+ Err(_) => {
1346+ model.error = Some("could not build poll request".into());
1347+ self.backoff_poll(model)
1348+ }
1349+ }
1350+ }
1351+ Event::PollWatchdog(gen) => {
1352+ // Only the poll this watchdog was armed for, and only while it is
1353+ // still the current one and still in flight. A watchdog whose poll
1354+ // already completed (or was superseded) is a stale timer and must
1355+ // not touch a healthy successor.
1356+ if gen != model.poll_gen || !model.polling {
1357+ return Command::done();
1358+ }
1359+ // Retire it as we give up on it. The request itself is not
1360+ // cancelled — nothing can cancel it — so if it *does* eventually
1361+ // resolve, the generation check below is what stops it from
1362+ // clearing the replacement poll's guard and forking the loop.
1363+ model.abandon_poll();
1364+ Command::event(Event::Poll)
1365+ }
1366+ Event::PollResult(gen, res) => {
1367+ // A result for a poll we already abandoned. Dropping the page is
1368+ // safe — and required. We never wrote a cursor for it, so the
1369+ // relay still holds every message in it and the current poll will
1370+ // be handed them again; whereas honouring it here would clear a
1371+ // guard that now belongs to a *different*, live poll, and the
1372+ // re-poll it chains would be a second loop re-polling itself
1373+ // forever alongside the first.
1374+ if gen != model.poll_gen {
1375+ return Command::done();
1376+ }
1377+ model.polling = false;
1378+ let Ok(mut resp) = res else {
1379+ return self.backoff_poll(model);
1380+ };
1381+ let status = u16::from(resp.status());
1382+ if status == 401 {
1383+ model.token = None;
1384+ return Command::event(Event::Authenticate);
1385+ }
1386+ if !(200..300).contains(&status) {
1387+ return self.backoff_poll(model);
1388+ }
1389+ model.poll_retries = 0;
1390+ // Ingesting a page into a model with a hole in it is the worst thing
1391+ // this client can do. The cursor write below is what lets the relay
1392+ // delete the mail we just took, so a page ingested into a model that
1393+ // cannot persist it is a page destroyed on both sides at once. Stop
1394+ // the poll loop instead — it restarts on the next launch, which
1395+ // re-runs the loads.
1396+ if model.kv_load_failed {
1397+ model.error = Some(
1398+ "storage read failed — restart the app; messages are not being saved"
1399+ .into(),
1400+ );
1401+ return render();
1402+ }
1403+ let bytes = resp.take_body().unwrap_or_default();
1404+ // Bound the response before parsing: a hostile relay can return
1405+ // a multi-gigabyte body that OOMs the app before `ingest_poll`'s
1406+ // per-blob budgets are even consulted.
1407+ if bytes.len() > MAX_POLL_RESPONSE_BYTES {
1408+ model.error = Some("relay sent an oversized poll response".into());
1409+ return self.backoff_poll(model);
1410+ }
1411+ // Q25: A malformed or unparseable poll response must not be
1412+ // treated as an empty page — `unwrap_or_default()` would adopt
1413+ // cursor 0, forcing a re-fetch from the beginning and wasting
1414+ // bandwidth on already-seen (deduped) messages. Back off instead.
1415+ let parsed: PollResp = match serde_json::from_slice(&bytes) {
1416+ Ok(p) => p,
1417+ Err(_) => {
1418+ model.error = Some("relay sent a malformed poll response".into());
1419+ return self.backoff_poll(model);
1420+ }
1421+ };
1422+ let cursor_before = model.cursor;
1423+ let cmd = self.ingest_poll(model, parsed);
1424+ // The re-poll hangs off the *cursor write*, not off this event.
1425+ // Polling again is what acks the batch we just took — the server
1426+ // deletes everything up to `cursor` — so it must not happen until
1427+ // the batch is durable on our side. `SavedCursor` re-polls.
1428+ //
1429+ // Arm a watchdog alongside the cursor write: if the shell ever
1430+ // drops the KV effect (suspension, bug), `SavedCursor` never fires,
1431+ // `polling` stays `false`, and the loop is dead until the next
1432+ // foreground `Connect`. The watchdog fires `Poll` if the loop is
1433+ // still idle — same pattern as the poll/send HTTP watchdogs.
1434+ //
1435+ // Skip the cursor write when the cursor did not advance (an empty
1436+ // or fully-deduped page): there is nothing new to persist, so
1437+ // re-poll directly. The watchdog is still armed as a fallback.
1438+ let cursor_advanced = model.cursor != cursor_before;
1439+ cmd.and(if cursor_advanced {
1440+ KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
1441+ .then_send(Event::SavedCursor)
1442+ } else {
1443+ Command::event(Event::Poll)
1444+ })
1445+ .and(
1446+ Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1447+ .0
1448+ .then_send(|_| Event::Poll),
1449+ )
1450+ .and(render())
1451+ }
1452+
1453+ // ---------------- outbox ----------------
1454+ Event::StartFlush => self.flush_next(model),
1455+ Event::FlushWatchdog(gen) => {
1456+ // Only the flush this watchdog was armed for, and only while it
1457+ // is still the current one and still in flight. A watchdog whose
1458+ // send already completed (or was superseded) is a stale timer and
1459+ // must not touch a healthy successor — same pattern as
1460+ // `PollWatchdog`.
1461+ if gen != model.flush_gen || !model.flushing {
1462+ return Command::done();
1463+ }
1464+ // Retire it as we give up on it. The request itself is not
1465+ // cancelled, so if it eventually resolves the generation check
1466+ // in `SendResult` is what stops it from popping the replacement's
1467+ // items.
1468+ model.abandon_flush();
1469+ self.flush_next(model)
1470+ }
1471+ Event::AuthWatchdog(gen) => {
1472+ // Only the auth this watchdog was armed for, and only while it is
1473+ // still the current one and still in flight.
1474+ if gen != model.auth_gen || !model.authenticating {
1475+ return Command::done();
1476+ }
1477+ model.abandon_auth();
1478+ Command::event(Event::Authenticate)
1479+ }
1480+ Event::SendResult(gen, Ok(resp)) => {
1481+ // A result for a send we already abandoned (a watchdog gave up
1482+ // on it, or a server change invalidated it). Dropping it is safe
1483+ // and required: its items were re-queued, so popping by count
1484+ // here would eat whichever messages slid into their place.
1485+ if gen != model.flush_gen {
1486+ return Command::done();
1487+ }
1488+ // The in-flight send is over; `flush_next` refuses to run while
1489+ // this is set, so it must be cleared before *any* branch below.
1490+ model.flushing = false;
1491+ let batch = model.in_flight.take();
1492+ let status = u16::from(resp.status());
1493+ if status == 401 {
1494+ model.token = None;
1495+ // The batch stays exactly as it is — re-auth, then re-send it.
1496+ return Command::event(Event::Authenticate);
1497+ }
1498+ if status == 429 {
1499+ // Rate-limited. Not a delivery failure and not the items'
1500+ // fault, so they are not charged a retry — a client that spent
1501+ // its delivery budget on the relay's load would drop perfectly
1502+ // good messages for being sent too enthusiastically. They stay
1503+ // queued untouched; we just wait.
1504+ //
1505+ // `Retry-After` is the relay saying how long. Only the
1506+ // delta-seconds form is read — the HTTP-date form falls back to
1507+ // our own backoff — and it is clamped either way, because
1508+ // obeying an unbounded number from a relay is how the outbox
1509+ // gets parked forever.
1510+ let delay = resp
1511+ .header("retry-after")
1512+ .map(|v| v.last().as_str())
1513+ .and_then(|v| v.trim().parse::<u64>().ok())
1514+ .map_or_else(
1515+ || {
1516+ // Nothing to honour, so guess — on the *send* loop's
1517+ // counter, not the poll loop's. `poll_retries` is reset
1518+ // by every page the poll loop takes, so a client polling
1519+ // happily while the relay throttles its sends would
1520+ // restart the send backoff at 3 s on every page, forever.
1521+ // That is not a backoff; it is a fixed 3 s retry dressed
1522+ // as one.
1523+ let ms = backoff_ms(model.send_retries);
1524+ model.send_retries = model.send_retries.saturating_add(1);
1525+ Duration::from_millis(ms)
1526+ },
1527+ |secs| {
1528+ Duration::from_millis(
1529+ secs.saturating_mul(1000).min(MAX_RETRY_AFTER_MS),
1530+ )
1531+ },
1532+ );
1533+ // Park the send loop for the duration, on the *model*. The timer
1534+ // below is not the only thing that fires `StartFlush` — a poll page
1535+ // and a fresh `SendText` both do — and without a pause they walk
1536+ // straight through the wait and hammer the relay that just asked us
1537+ // to stop. `flush_next` refuses to send until this passes, whoever
1538+ // wakes it.
1539+ model.flush_paused_until =
1540+ now_ms().saturating_add(i64::try_from(delay.as_millis()).unwrap_or(i64::MAX));
1541+ return Time::notify_after(delay)
1542+ .0
1543+ .then_send(|_| Event::StartFlush)
1544+ .and(render());
1545+ }
1546+ if status == 413 {
1547+ // The body outgrew what this relay accepts. `MAX_SEND_BATCH_BYTES`
1548+ // is only a guess at that cap, and this relay's is smaller — so
1549+ // treating the `413` as transient rebuilds the very same batch,
1550+ // which is rejected the very same way, forever.
1551+ //
1552+ // Measure the cap instead: halve what this batch actually
1553+ // weighed and rebuild under that. The items are untouched (we
1554+ // only pop on 200/400), so there is nothing to re-queue.
1555+ //
1556+ // Only a *splittable* batch takes this path. A lone item over
1557+ // the cap cannot be made any smaller, and halving the budget
1558+ // around it would spin — `flush_next` always takes the first
1559+ // item whatever it weighs. It falls through to `retry_batch`,
1560+ // whose budget is what eventually drops it, with an error the
1561+ // user can see.
1562+ if let Some(b) = batch.as_ref().filter(|b| b.count > 1) {
1563+ model.send_batch_budget = Some(b.bytes / 2);
1564+ return self.flush_next(model);
1565+ }
1566+ }
1567+ if (200..300).contains(&status) || status == 400 {
1568+ // The batch was accepted, or rejected outright (`self_send` /
1569+ // `invalid_message` — both permanent, and the relay takes a
1570+ // batch all-or-nothing). Either way it is finished with.
1571+ //
1572+ // The relay is taking our sends again, so the rate-limit backoff
1573+ // has done its job: forget it, or the *next* 429 — possibly hours
1574+ // later, under load that has nothing to do with this one — starts
1575+ // at whatever height the last one climbed to.
1576+ model.send_retries = 0;
1577+ // D3-app: Don't pop if kv is broken — the persist would be
1578+ // refused, and the items would be lost from memory but still
1579+ // on disk, causing duplicate sends on relaunch.
1580+ if !model.kv_load_failed {
1581+ for _ in 0..batch.map_or(0, |b| b.count) {
1582+ model.outbox.pop_front();
1583+ }
1584+ }
1585+ return self.persist_outbox(model).and(self.flush_next(model));
1586+ }
1587+ // A transient failure: a 5xx, a rate limit, a relay restarting.
1588+ self.retry_batch(model, batch)
1589+ }
1590+ Event::SendResult(gen, Err(_)) => {
1591+ // A transport error is the same transient failure as a 5xx.
1592+ if gen != model.flush_gen {
1593+ return Command::done();
1594+ }
1595+ model.flushing = false;
1596+ let batch = model.in_flight.take();
1597+ self.retry_batch(model, batch)
1598+ }
1599+ }
1600+ }
1601+
1602+ fn view(&self, model: &Model) -> ViewModel {
1603+ let page = match model.page {
1604+ Page::Conversations => "conversations",
1605+ Page::Chat => "chat",
1606+ Page::AddContact => "add_contact",
1607+ Page::Settings => "settings",
1608+ Page::EditProfile => "edit_profile",
1609+ };
1610+ let conn = match model.conn {
1611+ ConnStatus::Offline => "offline",
1612+ ConnStatus::Connecting => "connecting",
1613+ ConnStatus::Online => "online",
1614+ };
1615+
1616+ let active_peer = model.active_peer.clone().unwrap_or_default();
1617+
1618+ // `view()` is called after every event the core processes. The
1619+ // per-keystroke optimization below assumes `ComposeChanged` fires on
1620+ // every keystroke, but the Swift shell only sends it before `SendText`
1621+ // — so in practice this path runs once per message, not per keypress.
1622+ // The optimization is still correct (it withholds avatars on the chat
1623+ // page regardless of how often `view` runs), just the original comment
1624+ // overstated the frequency.
1625+ let chat_open = model.page == Page::Chat;
1626+
1627+ let mut contacts: Vec<ContactVM> = model
1628+ .contacts
1629+ .values()
1630+ .map(|c| {
1631+ let msgs = model.messages.get(&c.pubkey);
1632+ let last = msgs.and_then(|m| m.last());
1633+ let photo = if chat_open && c.pubkey != active_peer {
1634+ String::new()
1635+ } else {
1636+ c.photo.clone().unwrap_or_default()
1637+ };
1638+ ContactVM {
1639+ pubkey: c.pubkey.clone(),
1640+ name: c.label(),
1641+ ob: hex_to_ob(&c.pubkey),
1642+ photo,
1643+ blocked: c.blocked,
1644+ last_message: last.map(|m| m.body.clone()).unwrap_or_default(),
1645+ last_ts: last.map_or(0, |m| m.ts),
1646+ }
1647+ })
1648+ .collect();
1649+ contacts.sort_by(|a, b| b.last_ts.cmp(&a.last_ts).then(a.name.cmp(&b.name)));
1650+
1651+ let active_contact = model.contacts.get(&active_peer);
1652+ let messages: Vec<MessageVM> = model
1653+ .messages
1654+ .get(&active_peer)
1655+ .map(|m| {
1656+ m.iter()
1657+ .map(|s| MessageVM {
1658+ id: s.id.clone(),
1659+ body: s.body.clone(),
1660+ ts: s.ts,
1661+ outgoing: s.outgoing,
1662+ delivered: s.delivered,
1663+ })
1664+ .collect()
1665+ })
1666+ .unwrap_or_default();
1667+
1668+ ViewModel {
1669+ page: page.to_string(),
1670+ has_identity: model.secret_key.is_some(),
1671+ my_pubkey_hex: model.my_pubkey.clone(),
1672+ my_pubkey_ob: hex_to_ob(&model.my_pubkey),
1673+ conn_status: conn.to_string(),
1674+ server_url: model.settings.server_url.clone(),
1675+ profile: ProfileVM {
1676+ display_name: model.profile.display_name.clone(),
1677+ bio: model.profile.bio.clone(),
1678+ photo: model.profile.photo.clone().unwrap_or_default(),
1679+ },
1680+ contacts,
1681+ active_peer: active_peer.clone(),
1682+ active_peer_name: active_contact.map_or_else(
1683+ || trunc_ob(&active_peer),
1684+ Contact::label,
1685+ ),
1686+ active_peer_ob: hex_to_ob(&active_peer),
1687+ active_peer_blocked: active_contact.is_some_and(|c| c.blocked),
1688+ messages,
1689+ error: model.error.clone().unwrap_or_default(),
1690+ }
1691+ }
1692+}
1693+
1694+impl Skrepka {
1695+ /// One of the five startup kv loads landed. Fires `Connect` on the last one.
1696+ ///
1697+ /// Connecting earlier would let a poll land — and be persisted — against a
1698+ /// model that is still missing its contacts, cursor, or outbox. The loads
1699+ /// still in flight would then overwrite what the poll just ingested, and the
1700+ /// stale `cursor` (0) would make us re-fetch mail we had already acked.
1701+ fn startup_done(&self, model: &mut Model) -> Command<Effect, Event> {
1702+ if !model.startup_load_done() {
1703+ return Command::done();
1704+ }
1705+ if model.kv_load_failed {
1706+ // One of the loads could not be read (as opposed to being absent), so
1707+ // the model has a hole in it over data that is still on disk. Do not
1708+ // connect: a poll would ingest mail into that hole and ack it, and the
1709+ // relay would then delete the only copy of messages we never persisted.
1710+ //
1711+ // Everything downstream is already write-guarded, so this is belt and
1712+ // braces — but it is also the only place that can *say* so, and the
1713+ // recovery is a relaunch, which re-runs the loads.
1714+ model.error = Some(
1715+ "storage read failed — restart the app; changes will not be saved until you do"
1716+ .into(),
1717+ );
1718+ return render();
1719+ }
1720+ Command::event(Event::Connect)
1721+ }
1722+
1723+ /// The three model-state writes, and the one rule they share: a model that
1724+ /// failed to read a key must never write one.
1725+ ///
1726+ /// `persist_outbox` is the reason this matters. `ingest_poll` calls it on every
1727+ /// poll page — including an empty one — so a failed outbox read followed by a
1728+ /// single idle poll is enough to write `[]` over a queue of real unsent
1729+ /// messages. The user's mail is destroyed by a client doing nothing at all.
1730+ fn refuse_write(&self, model: &mut Model) -> bool {
1731+ if model.kv_load_failed {
1732+ model.error = Some("storage read failed — changes will not be saved".into());
1733+ return true;
1734+ }
1735+ false
1736+ }
1737+
1738+ fn persist_contacts(&self, model: &mut Model) -> Command<Effect, Event> {
1739+ if self.refuse_write(model) {
1740+ return Command::done();
1741+ }
1742+ let list: Vec<Contact> = model.contacts.values().cloned().collect();
1743+ KeyValue::set(K_CONTACTS, json_bytes(&list)).then_send(Event::Saved)
1744+ }
1745+
1746+ fn persist_profile(&self, model: &mut Model) -> Command<Effect, Event> {
1747+ if self.refuse_write(model) {
1748+ return Command::done();
1749+ }
1750+ KeyValue::set(K_PROFILE, json_bytes(&model.profile)).then_send(Event::Saved)
1751+ }
1752+
1753+ fn persist_outbox(&self, model: &mut Model) -> Command<Effect, Event> {
1754+ if self.refuse_write(model) {
1755+ return Command::done();
1756+ }
1757+ let list: Vec<OutboxItem> = model.outbox.iter().cloned().collect();
1758+ KeyValue::set(K_OUTBOX, json_bytes(&list)).then_send(Event::Saved)
1759+ }
1760+
1761+ fn persist_messages(&self, model: &mut Model, peer: &str) -> Command<Effect, Event> {
1762+ if self.refuse_write(model) {
1763+ return Command::done();
1764+ }
1765+ let list = model.messages.get(peer).cloned().unwrap_or_default();
1766+ KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
1767+ }
1768+
1769+ /// Q18: Persist the per-peer dedup ID set. Written alongside messages so
1770+ /// the set survives restarts and covers IDs that `trim_history` has aged
1771+ /// out of the conversation.
1772+ fn persist_seen_ids(&self, model: &mut Model) -> Command<Effect, Event> {
1773+ if self.refuse_write(model) {
1774+ return Command::done();
1775+ }
1776+ KeyValue::set(K_SEEN_IDS, json_bytes(&model.seen_ids)).then_send(Event::Saved)
1777+ }
1778+
1779+ fn schedule_reconnect(&self, model: &mut Model) -> Command<Effect, Event> {
1780+ let delay = backoff_ms(model.poll_retries);
1781+ model.poll_retries = model.poll_retries.saturating_add(1);
1782+ Time::notify_after(Duration::from_millis(delay))
1783+ .0
1784+ .then_send(|_| Event::Authenticate)
1785+ .and(render())
1786+ }
1787+
1788+ /// The poll loop stalled — retry after a backoff, and stop claiming to be online.
1789+ ///
1790+ /// Every path here (a transport error, a 5xx, a failed cursor write) means the loop
1791+ /// is not healthy, so leaving `conn` at `Online` both lies to the status dot and —
1792+ /// worse — makes `Connect` a no-op, because it stands down when it already believes
1793+ /// it is online. The shell fires `Connect` on every foreground precisely to recover
1794+ /// the long-poll that backgrounding killed; without this it recovers nothing.
1795+ fn backoff_poll(&self, model: &mut Model) -> Command<Effect, Event> {
1796+ model.conn = ConnStatus::Offline;
1797+ let delay = backoff_ms(model.poll_retries);
1798+ model.poll_retries = model.poll_retries.saturating_add(1);
1799+ Time::notify_after(Duration::from_millis(delay))
1800+ .0
1801+ .then_send(|_| Event::Poll)
1802+ .and(render())
1803+ }
1804+
1805+ fn send_text(&self, model: &mut Model) -> Command<Effect, Event> {
1806+ let Some(peer) = model.active_peer.clone() else {
1807+ return render();
1808+ };
1809+ // Clear a stale error from a previous failed send, so the error display
1810+ // does not persist after the user fixes the issue and sends again.
1811+ model.error = None;
1812+ // Blocking cuts the peer off in both directions (PROTOCOL.md §4). The
1813+ // ingest side drops their messages; the send side must refuse too, or
1814+ // a blocked peer still receives our texts, acks, and the liveness
1815+ // signal that blocking is supposed to cut off.
1816+ if model.contacts.get(&peer).is_some_and(|c| c.blocked) {
1817+ model.error = Some("cannot send to a blocked contact".into());
1818+ return render();
1819+ }
1820+ let body = model.compose.trim().to_string();
1821+ if body.is_empty() {
1822+ return render();
1823+ }
1824+ // The recipient's `parse_payload` enforces `MAX_BODY_LEN` and silently
1825+ // drops oversized text. Without this check the message would be stored
1826+ // locally, sent to the relay, and marked "sent" — but never arrive, never
1827+ // get acked, and the sender would see it stuck as "sent" forever with no
1828+ // error.
1829+ if body.len() > protocol::MAX_BODY_LEN {
1830+ model.error = Some("message too long".into());
1831+ return render();
1832+ }
1833+
1834+ // Check the contact cap *before* mutating anything. The old order
1835+ // cleared `compose` and inserted the message into `model.messages`
1836+ // first, then hit the cap and returned early — so the user's text was
1837+ // gone (compose cleared), the message sat in an invisible orphan
1838+ // conversation (no contact entry to click on), and nothing was
1839+ // persisted (no `persist_messages` before the early return). On next
1840+ // launch the message was silently lost.
1841+ let was_new = !model.contacts.contains_key(&peer);
1842+ if was_new && model.contacts.len() >= MAX_CONTACTS {
1843+ model.error = Some("contact list is full".into());
1844+ return render();
1845+ }
1846+
1847+ model.compose.clear();
1848+ let ts = now_ms();
1849+ let id = uuid::Uuid::new_v4().to_string();
1850+
1851+ let convo = model.messages.entry(peer.clone()).or_default();
1852+ // Insert in order rather than appending: an incoming message may hold a
1853+ // `ts` up to `MAX_FUTURE_SKEW_MS` ahead of ours, so appending would leave
1854+ // the conversation unsorted — and `trim_history` ages out the *front*.
1855+ insert_sorted(
1856+ convo,
1857+ StoredMessage {
1858+ id: id.clone(),
1859+ body: body.clone(),
1860+ ts,
1861+ outgoing: true,
1862+ delivered: false,
1863+ },
1864+ );
1865+ trim_history(convo);
1866+
1867+ // Auto-create a bare contact if messaging a brand-new key, respecting
1868+ // the same `MAX_CONTACTS` cap `ingest_poll` enforces. This is
1869+ // user-driven (not an attack vector), but the cap exists to bound the
1870+ // `contacts` kv blob, which is rewritten in full on every change.
1871+ if was_new {
1872+ model
1873+ .contacts
1874+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1875+ }
1876+
1877+ let text = Payload::Text { id, body };
1878+ model.outbox.push_back(OutboxItem::new(
1879+ peer.clone(),
1880+ Arc::new(protocol::serialize_payload(&text, ts)),
1881+ ));
1882+
1883+ // On first contact, share our profile *after* the text. The text
1884+ // auto-creates the contact on the recipient's side; the profile that
1885+ // follows in the same batch then applies to the now-existing contact.
1886+ // (Previously profile was queued before the text, but the recipient's
1887+ // ingest drops profiles from non-contacts, so the profile was lost.)
1888+ if was_new {
1889+ let profile = Payload::Profile {
1890+ display_name: Some(model.profile.display_name.clone()),
1891+ bio: Some(model.profile.bio.clone()),
1892+ photo: model.profile.photo.clone(),
1893+ };
1894+ model.outbox.push_back(OutboxItem::profile(
1895+ peer.clone(),
1896+ Arc::new(protocol::serialize_payload(&profile, ts)),
1897+ ));
1898+ }
1899+
1900+ Command::all([
1901+ self.persist_messages(model, &peer),
1902+ self.persist_contacts(model),
1903+ self.persist_outbox(model),
1904+ ])
1905+ .and(Command::event(Event::StartFlush))
1906+ .and(render())
1907+ }
1908+
1909+ /// Encrypt and send a batch from the head of the outbox, discarding any
1910+ /// unsendable items in the way.
1911+ ///
1912+ /// The batch is the longest run of items at the head that share a recipient,
1913+ /// bounded by `MAX_SEND_BATCH` and `MAX_SEND_BATCH_BYTES`. Sending them one
1914+ /// at a time meant an HTTP round-trip per message — and per *ack*, which
1915+ /// `ingest_poll` queues one of per sender per page, so catching up after a
1916+ /// long offline stretch was one request per message received. The relay takes
1917+ /// up to a hundred per request (PROTOCOL.md §7); take it up on that.
1918+ ///
1919+ /// Stopping at the first recipient change keeps the outbox's FIFO ordering
1920+ /// intact — a batch is delivered as a unit, so hoisting a later item for the
1921+ /// same peer over an earlier one for a different peer could only reorder
1922+ /// *across* conversations, but it would also let one busy peer starve the
1923+ /// rest of the queue. Consecutive-only is the cheap, obviously-fair rule, and
1924+ /// it already collapses the two cases that matter: a burst of texts to the
1925+ /// open chat, and a page's worth of acks back to one sender.
1926+ ///
1927+ /// The discards happen in one pass, followed by a single `persist_outbox`.
1928+ /// The previous shape recursed once per bad item and emitted a full outbox
1929+ /// rewrite each time, so a contact list holding `n` unusable keys cost
1930+ /// O(n) kv writes of an O(n) blob — and `n` stack frames — to reach the first
1931+ /// item that could actually go out.
1932+ fn flush_next(&self, model: &mut Model) -> Command<Effect, Event> {
1933+ if model.flushing {
1934+ return Command::done();
1935+ }
1936+ // A failed startup read means the outbox in memory is not the outbox on
1937+ // disk. Sending from it is not the danger — `persist_outbox` refusing to
1938+ // write is — but a send that cannot record having happened is one that will
1939+ // happen again on the next launch, from the queue still on disk. Sit still.
1940+ if model.kv_load_failed {
1941+ return Command::done();
1942+ }
1943+ // A 429 parked the loop until the far side of the relay's `Retry-After`.
1944+ // The timer that set the pause also scheduled the `StartFlush` that resumes
1945+ // us, so whoever woke us early — a poll page, a fresh `SendText` — the
1946+ // answer is the same: the relay asked us to stop until then.
1947+ if now_ms() < model.flush_paused_until {
1948+ return Command::done();
1949+ }
1950+ let (Some(id), Some(token)) = (model.identity(), model.token.clone()) else {
1951+ return Command::done();
1952+ };
1953+
1954+ let now = now_ms();
1955+ let mut dirty = false;
1956+ let mut recipient: Option<String> = None;
1957+ let mut messages: Vec<Envelope> = Vec::new();
1958+ let mut bytes = 0usize;
1959+ // What we believe this relay's body cap to be — `MAX_SEND_BATCH_BYTES`
1960+ // until a `413` tells us otherwise.
1961+ let batch_budget = model.send_batch_budget.unwrap_or(MAX_SEND_BATCH_BYTES);
1962+
1963+ while messages.len() < MAX_SEND_BATCH {
1964+ let Some(item) = model.outbox.get(messages.len()) else {
1965+ break;
1966+ };
1967+ if recipient.as_ref().is_some_and(|r| r != &item.recipient) {
1968+ break;
1969+ }
1970+ let Some(blob) = encrypt_for(&id, item) else {
1971+ // An unusable recipient key can never be encrypted to, so the item
1972+ // has to go or it blocks the head of the outbox forever — but say
1973+ // so, rather than letting the message vanish. (Only reachable at
1974+ // the head: an item deeper in the batch shares the recipient of one
1975+ // we have already encrypted to, so its key is good by construction.)
1976+ model.error = Some(format!(
1977+ "cannot send to {}: invalid key",
1978+ trunc_ob(&item.recipient)
1979+ ));
1980+ model.outbox.remove(messages.len());
1981+ dirty = true;
1982+ continue;
1983+ };
1984+ let hex_blob = hex::encode(blob);
1985+ // Always take the first item, whatever it weighs — a lone oversized
1986+ // payload must still reach the relay, which rejects it permanently
1987+ // (`400`) and so gets it out of the queue for good. Past the first,
1988+ // stop before the body outgrows what a relay will accept.
1989+ if !messages.is_empty() && bytes + hex_blob.len() > batch_budget {
1990+ break;
1991+ }
1992+ bytes += hex_blob.len();
1993+ recipient = Some(item.recipient.clone());
1994+ messages.push(Envelope {
1995+ to: item.recipient.clone(),
1996+ encrypted_blob: hex_blob,
1997+ });
1998+ }
1999+
2000+ let Some(recipient) = recipient else {
2001+ // Nothing left to send. Render only if we dropped something, so the
2002+ // user sees why their message went away.
2003+ return if dirty {
2004+ self.persist_outbox(model).and(render())
2005+ } else {
2006+ Command::done()
2007+ };
2008+ };
2009+
2010+ // Stamp the items now going out for the first time. This is what the TTL
2011+ // in `OutboxItem::is_expired` measures from, so it has to be durable —
2012+ // otherwise a relaunch resets the clock and a permanently-stuck item is
2013+ // immortal again.
2014+ for item in model.outbox.iter_mut().take(messages.len()) {
2015+ if item.first_attempt == 0 {
2016+ item.first_attempt = now;
2017+ dirty = true;
2018+ }
2019+ }
2020+ let persist = if dirty {
2021+ self.persist_outbox(model)
2022+ } else {
2023+ Command::done()
2024+ };
2025+
2026+ model.flushing = true;
2027+ let gen = model.next_flush_gen();
2028+ model.in_flight = Some(InFlight {
2029+ recipient,
2030+ count: messages.len(),
2031+ bytes,
2032+ });
2033+ let url = format!("{}/messages", model.settings.server_url);
2034+ match Http::post(url)
2035+ .header("authorization", format!("Bearer {token}"))
2036+ .body_json(&SendBatch { messages })
2037+ {
2038+ Ok(req) => persist.and(req.build().then_send(move |r| Event::SendResult(gen, r)))
2039+ .and(
2040+ Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
2041+ .0
2042+ .then_send(move |_| Event::FlushWatchdog(gen)),
2043+ ),
2044+ Err(_) => {
2045+ // Not a dead loop: the next StartFlush (from a poll page or a
2046+ // fresh send) retries the head of the outbox.
2047+ model.flushing = false;
2048+ model.in_flight = None;
2049+ model.error = Some("could not build send request".into());
2050+ persist.and(render())
2051+ }
2052+ }
2053+ }
2054+
2055+ /// A send failed transiently (a 5xx, a rate limit, a dropped connection). The
2056+ /// batch stays queued and goes out again on the next `StartFlush` — but not
2057+ /// forever.
2058+ ///
2059+ /// The outbox is a strict FIFO, so an item that can never be delivered blocks
2060+ /// every message queued behind it, permanently: a single unreachable recipient
2061+ /// relay used to be enough to silence the client for good. Charge every item in
2062+ /// the failed batch a retry and drop the ones that have exhausted their budget
2063+ /// (`OutboxItem::is_expired`) — with an error, rather than letting them vanish.
2064+ fn retry_batch(&self, model: &mut Model, batch: Option<InFlight>) -> Command<Effect, Event> {
2065+ // `None` means the items were removed out from under the send (the peer was
2066+ // blocked, or deleted). There is nothing left to charge or to drop.
2067+ let Some(batch) = batch else {
2068+ return render();
2069+ };
2070+
2071+ let now = now_ms();
2072+ let mut expired = 0usize;
2073+ let mut kept: VecDeque<OutboxItem> = VecDeque::with_capacity(model.outbox.len());
2074+ for (i, mut item) in std::mem::take(&mut model.outbox).into_iter().enumerate() {
2075+ if i < batch.count {
2076+ item.retries = item.retries.saturating_add(1);
2077+ if item.is_expired(now) {
2078+ expired += 1;
2079+ continue;
2080+ }
2081+ }
2082+ kept.push_back(item);
2083+ }
2084+ model.outbox = kept;
2085+
2086+ if expired == 0 {
2087+ // Nothing dropped, but the retry counters still have to be durable: a
2088+ // relaunch that handed every stuck item a fresh budget would wedge the
2089+ // outbox across restarts exactly as it did before there was a budget.
2090+ return self.persist_outbox(model).and(render());
2091+ }
2092+ model.error = Some(format!(
2093+ "gave up sending {expired} message(s) to {}",
2094+ trunc_ob(&batch.recipient)
2095+ ));
2096+ // The head of the outbox has moved, so whatever was stuck behind the items
2097+ // we just dropped can finally go out.
2098+ self.persist_outbox(model)
2099+ .and(self.flush_next(model))
2100+ .and(render())
2101+ }
2102+
2103+ /// Decrypt and fold a poll page into the model; queue delivery acks.
2104+ fn ingest_poll(&self, model: &mut Model, page: PollResp) -> Command<Effect, Event> {
2105+ // The caller already refuses to poll into a broken model, and must — the
2106+ // cursor write is its own, and advancing it is what acks the page. This
2107+ // stands guard for whoever calls this next: ingesting is only safe if what
2108+ // is ingested can be written down.
2109+ if model.kv_load_failed {
2110+ return Command::done();
2111+ }
2112+ let prev_cursor = model.cursor;
2113+ // Clamp the relay-supplied cursor: honest cursors are wall-clock-seeded
2114+ // sequence numbers, so anything far in the future is a hostile or buggy
2115+ // relay. Adopting `i64::MAX` would ack (delete) all queued mail on the
2116+ // next poll and persist that poison across relaunches.
2117+ let clamped_cursor = page.cursor.min(now_ms().saturating_add(60_000));
2118+ // Q16: Only advance the cursor when the page actually carried events.
2119+ // An empty page with a cursor higher than ours is suspicious — a
2120+ // compromised relay could poison the cursor to ack (delete) messages
2121+ // the client hasn't seen. When there are no events, there is nothing
2122+ // to acknowledge, so the cursor should not move. (An honest relay's
2123+ // empty-page cursor equals what the client sent, so this is a no-op
2124+ // in the normal case.)
2125+ if !page.events.is_empty() && clamped_cursor > model.cursor {
2126+ model.cursor = clamped_cursor;
2127+ }
2128+ let Some(id) = model.identity() else {
2129+ return Command::done();
2130+ };
2131+
2132+ let now = now_ms();
2133+ let mut touched_convos: HashSet<String> = HashSet::new();
2134+ let mut contacts_dirty = false;
2135+ let mut outbox_dirty = false;
2136+ // sender hex -> message ids needing acks
2137+ let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
2138+ // Q17: Per-sender deferred ack accumulation. Collected during the event
2139+ // loop, applied after it — so relay page ordering cannot cause an older
2140+ // ack to be dropped as stale by a newer one processed first.
2141+ let mut pending_acks: HashMap<String, PendingAck> = HashMap::new();
2142+
2143+ let mut budget = MAX_POLL_TOTAL_BYTES;
2144+ let mut events_processed = 0usize;
2145+ // N9: Track whether any event was successfully decrypted and stored.
2146+ // When the budget/count is exhausted mid-page, we ack the page (keep
2147+ // the advanced cursor) only if at least one event was processed —
2148+ // the unprocessed tail is from a hostile/misconfigured relay and
2149+ // losing them is better than livelocking. If zero events were
2150+ // processed (a completely hostile page), restore the cursor so we
2151+ // don't ack mail we never saw.
2152+ let mut events_stored = 0usize;
2153+ for ev in page.events {
2154+ // Reject an oversized blob on its hex length, before it costs us a
2155+ // decode. No conforming sender produces one (the relay caps blobs at
2156+ // the same size), so this can only be junk.
2157+ if ev.encrypted_blob.len() > MAX_BLOB_LEN * 2 {
2158+ continue;
2159+ }
2160+ // Event count cap: the byte budget alone permits ~388K minimum-size
2161+ // events, each costing a full decrypt attempt. A hostile relay can
2162+ // freeze the app for tens of seconds per poll with a page of small
2163+ // junk blobs. Stop processing past this count.
2164+ if events_processed >= MAX_POLL_EVENTS {
2165+ // N9: If we stored at least one event, keep the advanced cursor
2166+ // to ack the page and avoid a livelock. The unprocessed events
2167+ // are hostile junk or beyond our capacity — we can't process
2168+ // them anyway, and re-fetching them forever would wedge the
2169+ // client.
2170+ if events_stored == 0 {
2171+ model.cursor = prev_cursor;
2172+ }
2173+ model.error = Some("relay sent too many poll events".into());
2174+ break;
2175+ }
2176+ events_processed += 1;
2177+ match budget.checked_sub(ev.encrypted_blob.len() / 2) {
2178+ Some(left) => budget = left,
2179+ None => {
2180+ // N9: Budget exhausted. If we stored at least one event,
2181+ // keep the advanced cursor to ack the page — re-fetching
2182+ // an oversized page forever is a livelock. The unprocessed
2183+ // events are beyond our capacity and would be re-served
2184+ // on every poll, wedging the client. Acking them breaks
2185+ // the loop. If zero events were stored (completely hostile
2186+ // page), restore the cursor to avoid acking mail we
2187+ // never saw.
2188+ if events_stored == 0 {
2189+ model.cursor = prev_cursor;
2190+ }
2191+ model.error = Some("relay sent an oversized poll page".into());
2192+ break;
2193+ }
2194+ }
2195+ let Ok(blob) = hex::decode(&ev.encrypted_blob) else {
2196+ continue;
2197+ };
2198+ let Ok(dec) = crate::crypto::decrypt(&id, &blob) else {
2199+ continue;
2200+ };
2201+ let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
2202+ continue;
2203+ };
2204+ // D1: Any event that was successfully decrypted AND parsed counts
2205+ // as "stored" for cursor advancement — the client has processed it
2206+ // even if it was a duplicate/stale/stranger that was dropped.
2207+ events_stored += 1;
2208+ let sender = dec.sender_hex;
2209+
2210+ // Mail from ourselves. A relay cannot forge this — the sender identity
2211+ // is signed inside the AEAD — but it can *echo back* a blob we sent
2212+ // through it, and one addressed to ourselves decrypts perfectly well.
2213+ // Nothing downstream would notice: it would auto-create a contact for
2214+ // our own key (which `AddContact` explicitly refuses to do), file our
2215+ // own words as an incoming message, and queue a delivery ack addressed
2216+ // to us — which flushes, comes back, and is acked again.
2217+ if sender == model.my_pubkey {
2218+ continue;
2219+ }
2220+
2221+ // Blocking cuts the peer off entirely (PROTOCOL.md §4) — not just
2222+ // their texts. A blocked peer must not be able to rewrite their
2223+ // display name and avatar in our contact list, mark our messages
2224+ // delivered, or (via an ack we send back) learn that we are online
2225+ // and reading. So the gate sits above the payload match, not inside
2226+ // the `Text` arm.
2227+ if model.contacts.get(&sender).is_some_and(|c| c.blocked) {
2228+ continue;
2229+ }
2230+
2231+ // `ts` is chosen by the sender and drives conversation ordering,
2232+ // in-chat ordering, and the displayed time. Bound it to the near
2233+ // future so a peer cannot pin itself to the top of the list forever
2234+ // with `ts = i64::MAX`, and bound it to the non-negative past so a
2235+ // malicious peer cannot make their own message invisible by dating
2236+ // it at `i64::MIN` — `insert_sorted` would put it at position 0 and
2237+ // `trim_history` would age it out first.
2238+ let ts = parsed
2239+ .ts
2240+ .max(0)
2241+ .min(now.saturating_add(MAX_FUTURE_SKEW_MS));
2242+ let known = model.contacts.contains_key(&sender);
2243+ match parsed.payload {
2244+ Payload::Text { id: msg_id, body } => {
2245+ // Auto-create a contact for a stranger who writes to us — but
2246+ // only up to `MAX_CONTACTS`. Past that, drop the message
2247+ // entirely: storing it would mean a `messages:<peer>` blob for
2248+ // a peer with no contact entry, i.e. a conversation nothing
2249+ // can open and nothing ever trims.
2250+ if !known {
2251+ if model.contacts.len() >= MAX_CONTACTS {
2252+ continue;
2253+ }
2254+ model.contacts.insert(
2255+ sender.clone(),
2256+ Contact::new(sender.clone(), String::new(), ts),
2257+ );
2258+ contacts_dirty = true;
2259+ // D6-app: Send our profile to the new contact so they
2260+ // see our display name and avatar, not just our @p.
2261+ let profile = Payload::Profile {
2262+ display_name: Some(model.profile.display_name.clone()),
2263+ bio: Some(model.profile.bio.clone()),
2264+ photo: model.profile.photo.clone(),
2265+ };
2266+ model.outbox.push_back(OutboxItem::profile(
2267+ sender.clone(),
2268+ Arc::new(protocol::serialize_payload(&profile, ts)),
2269+ ));
2270+ outbox_dirty = true;
2271+ }
2272+ let convo = model.messages.entry(sender.clone()).or_default();
2273+ // Q18: Use the persistent per-peer dedup set from
2274+ // `model.seen_ids` (loaded from kv at startup) instead of
2275+ // deriving it from the trimmed conversation. Without this,
2276+ // a replayed blob whose `id` was aged out by `trim_history`
2277+ // (past the 1000-message cap) passes the dedup check and
2278+ // re-enters the conversation.
2279+ let id_set = model
2280+ .seen_ids
2281+ .entry(sender.clone())
2282+ .or_default();
2283+ // Also seed from the conversation in case the kv load was
2284+ // missing or partial — covers the upgrade path.
2285+ if id_set.is_empty() {
2286+ id_set.extend(convo.iter().map(|m| m.id.clone()));
2287+ }
2288+ // dedup by id — O(1) via HashSet
2289+ if !id_set.contains(&msg_id) {
2290+ // The conversation is *kept* sorted rather than re-sorted:
2291+ // a `sort_by_key` per arrival is O(n log n) on a list of up
2292+ // to `MAX_MESSAGES_PER_PEER`, every time, and a poll page
2293+ // can carry hundreds of messages.
2294+ insert_sorted(
2295+ convo,
2296+ StoredMessage {
2297+ id: msg_id.clone(),
2298+ body,
2299+ ts,
2300+ outgoing: false,
2301+ delivered: false,
2302+ },
2303+ );
2304+ id_set.insert(msg_id.clone());
2305+ // N7: Cap the dedup set so it doesn't grow without
2306+ // bound. The set is rewritten to kv on every message
2307+ // receipt, so an unbounded set makes every receipt
2308+ // slower. Keep the most recent IDs by draining excess
2309+ // from the oldest. `HashSet` has no ordering, so when
2310+ // the cap is exceeded we rebuild from the conversation
2311+ // (which is trimmed to MAX_MESSAGES_PER_PEER and
2312+ // sorted by ts) — the oldest IDs in the set that are
2313+ // no longer in the conversation are the ones to drop.
2314+ if id_set.len() > MAX_SEEN_IDS_PER_PEER {
2315+ let convo_ids: HashSet<&String> =
2316+ convo.iter().map(|m| &m.id).collect();
2317+ let excess = id_set.len() - MAX_SEEN_IDS_PER_PEER;
2318+ let to_remove: Vec<String> = id_set
2319+ .iter()
2320+ .filter(|id| !convo_ids.contains(*id))
2321+ .take(excess)
2322+ .cloned()
2323+ .collect();
2324+ for id in to_remove {
2325+ id_set.remove(&id);
2326+ }
2327+ }
2328+ trim_history(convo);
2329+ touched_convos.insert(sender.clone());
2330+ // Only ack a message we actually stored — a duplicate id
2331+ // was already acked when its first copy was ingested.
2332+ ack_targets.entry(sender.clone()).or_default().push(msg_id);
2333+ }
2334+ }
2335+ Payload::DeliveryAck { ack_ids } => {
2336+ // Q17: Defer the staleness check until after the event loop.
2337+ // Within a single page, a relay can reorder events so that a
2338+ // newer ack (higher ts) is processed before an older one. If
2339+ // we applied the watermark immediately, the older ack would be
2340+ // dropped as stale — even if it acks different message IDs.
2341+ // Instead, accumulate all ack_ids per sender and the max ts,
2342+ // then apply them in a second pass below the loop.
2343+ let prev_ts = pending_acks
2344+ .get(&sender)
2345+ .map(|p| p.ts)
2346+ .unwrap_or(0);
2347+ if ts > prev_ts {
2348+ let entry = pending_acks.entry(sender.clone()).or_default();
2349+ entry.ts = ts;
2350+ }
2351+ pending_acks
2352+ .entry(sender.clone())
2353+ .or_default()
2354+ .ids
2355+ .extend(ack_ids);
2356+ }
2357+ Payload::Profile {
2358+ display_name,
2359+ bio,
2360+ photo,
2361+ } => {
2362+ // A bare profile from a stranger creates nothing. It is an
2363+ // unsolicited push with no message attached, so honouring it
2364+ // would let anyone holding our pubkey put an entry (with an
2365+ // avatar) in our contact list without ever saying a word.
2366+ let Some(c) = model.contacts.get_mut(&sender) else {
2367+ continue;
2368+ };
2369+ // Ignore stale profile replays (PROTOCOL.md §4). The watermark
2370+ // is clamped to *now* for the same reason `last_ack_ts` is: a
2371+ // peer that parked it in the future would freeze its own
2372+ // profile, since every honest update it sent afterwards would
2373+ // read as a stale replay of itself.
2374+ //
2375+ // `>=` (not `>`): a contact's `last_profile_ts` starts at 0,
2376+ // and a first profile whose `ts` is also 0 — the default when
2377+ // the field is missing — must still be accepted. `>` would
2378+ // silently drop it, leaving the contact with no profile at all.
2379+ // A replayed profile at the same `ts` overwrites with identical
2380+ // content, which is a no-op in practice.
2381+ if ts >= c.last_profile_ts {
2382+ // D13: Only update fields that are present (Some).
2383+ // An absent field (None) means "don't change".
2384+ if let Some(name) = display_name {
2385+ c.display_name = name;
2386+ }
2387+ if let Some(b) = bio {
2388+ c.bio = b;
2389+ }
2390+ if let Some(p) = photo {
2391+ c.photo = Some(p);
2392+ }
2393+ c.last_profile_ts = ts.min(now);
2394+ contacts_dirty = true;
2395+ }
2396+ }
2397+ }
2398+ }
2399+
2400+ // Q17: Apply deferred delivery acks. Collected during the event loop
2401+ // above, now applied with a single staleness check per sender using the
2402+ // highest `ts` seen — so relay page ordering cannot cause an older ack
2403+ // to be dropped as stale by a newer one processed first.
2404+ for (sender, pending) in &pending_acks {
2405+ let stale = model
2406+ .contacts
2407+ .get(sender)
2408+ .is_some_and(|c| pending.ts < c.last_ack_ts);
2409+ if stale {
2410+ continue;
2411+ }
2412+ // The watermark is clamped to *now*, not merely into the skew window:
2413+ // `ts` is the peer's to choose, and `last_ack_ts` is what every later
2414+ // ack is measured against. A single ack at `ts = i64::MAX` would
2415+ // otherwise park the watermark a minute into the future, and every
2416+ // honest ack that followed — the ones that actually mark our messages
2417+ // delivered — would read as stale and be dropped.
2418+ if let Some(c) = model.contacts.get_mut(sender) {
2419+ c.last_ack_ts = pending.ts.min(now);
2420+ contacts_dirty = true;
2421+ }
2422+ if let Some(convo) = model.messages.get_mut(sender) {
2423+ let mut changed = false;
2424+ for m in convo.iter_mut() {
2425+ if m.outgoing && pending.ids.contains(&m.id) && !m.delivered {
2426+ m.delivered = true;
2427+ changed = true;
2428+ }
2429+ }
2430+ if changed {
2431+ touched_convos.insert(sender.clone());
2432+ }
2433+ }
2434+ }
2435+
2436+ // Queue delivery acks, in batches the recipient will actually accept.
2437+ //
2438+ // `parse_payload` drops a `delivery.ack` carrying more than `MAX_ACK_IDS`
2439+ // ids, and a peer running this same code applies that cap to *us*. One
2440+ // payload per sender therefore silently stops working the moment a peer
2441+ // has more than `MAX_ACK_IDS` messages waiting for us — which is exactly
2442+ // what a long offline stretch produces — and every one of those messages
2443+ // stays un-delivered on their side forever, because a poll page is only
2444+ // ever acked once.
2445+ let ack_ts = now_ms();
2446+ for (sender, ids) in ack_targets {
2447+ for batch in ids.chunks(protocol::MAX_ACK_IDS) {
2448+ let payload = Payload::DeliveryAck {
2449+ ack_ids: batch.to_vec(),
2450+ };
2451+ // Consecutive and same-recipient, so `flush_next` puts every one of
2452+ // a sender's ack batches into a single `/messages` request.
2453+ model.outbox.push_back(OutboxItem::new(
2454+ sender.clone(),
2455+ Arc::new(protocol::serialize_payload(&payload, ack_ts)),
2456+ ));
2457+ }
2458+ outbox_dirty = true;
2459+ }
2460+
2461+ // Persist everything touched.
2462+ let mut cmds: Vec<Command<Effect, Event>> = Vec::new();
2463+ for peer in &touched_convos {
2464+ cmds.push(self.persist_messages(model, peer));
2465+ }
2466+ // Q18: Persist the dedup ID set whenever we touched conversations.
2467+ if !touched_convos.is_empty() {
2468+ cmds.push(self.persist_seen_ids(model));
2469+ }
2470+ if contacts_dirty {
2471+ cmds.push(self.persist_contacts(model));
2472+ }
2473+ if outbox_dirty {
2474+ cmds.push(self.persist_outbox(model));
2475+ }
2476+ cmds.push(Command::event(Event::StartFlush));
2477+ Command::all(cmds)
2478+ }
2479+}
2480+
2481+/// Encrypt one outbox item to its recipient, or `None` if the recipient's key is
2482+/// unusable — a bad hex string, the wrong length, or 32 bytes that aren't a curve
2483+/// point. `AddContact` rejects all three now, but an older contact list (or a
2484+/// profile-broadcast fan-out over one) may still hold such a key.
2485+fn encrypt_for(sender: &Identity, item: &OutboxItem) -> Option<Vec<u8>> {
2486+ let recipient: [u8; 32] = hex::decode(&item.recipient).ok()?.try_into().ok()?;
2487+ let mut rng = rand_core::OsRng;
2488+ crate::crypto::encrypt(&mut rng, sender, &recipient, item.envelope_json.as_bytes()).ok()
2489+}
2490+
2491+fn backoff_ms(retries: u32) -> u64 {
2492+ (3000u64.saturating_mul(1u64 << retries.min(4))).min(30_000)
2493+}
2494+
2495+/// Insert a message into a conversation held in `ts` order.
2496+///
2497+/// `partition_point` lands *after* every message sharing the new one's `ts`, so
2498+/// ties keep arrival order — exactly what the stable `sort_by_key` this replaces
2499+/// produced, at O(log n) + a shift instead of O(n log n) on every message.
2500+///
2501+/// This is what makes "sorted by `ts`" an invariant rather than something
2502+/// `ingest_poll` restored after the fact, which is what `trim_history` has always
2503+/// assumed (it ages out the *front*) and what `view()` renders in order.
2504+fn insert_sorted(convo: &mut Vec<StoredMessage>, msg: StoredMessage) {
2505+ let pos = convo.partition_point(|m| m.ts <= msg.ts);
2506+ convo.insert(pos, msg);
2507+}
2508+
2509+/// Age out local history: keep only the most recent `MAX_MESSAGES_PER_PEER`
2510+/// messages of a conversation (PROTOCOL.md §9). Callers keep `convo` sorted by
2511+/// `ts`, so the oldest messages are at the front. The subsequent kv write
2512+/// persists the trimmed list, bounding the `messages:<peer>` blob too.
2513+fn trim_history(convo: &mut Vec<StoredMessage>) {
2514+ if let Some(excess) = convo.len().checked_sub(MAX_MESSAGES_PER_PEER) {
2515+ convo.drain(..excess);
2516+ }
2517+}
2518+
2519+/// Decode a kv `get` result into a typed value.
2520+///
2521+/// Returns `Ok(Some(value))` when the key exists and deserializes, `Ok(None)`
2522+/// when the key is absent (a fresh install), and `Err` when the read itself
2523+/// failed (e.g. the file is locked or corrupt). The caller MUST NOT overwrite
2524+/// a key with a default when the read failed — the data is still on disk, and
2525+/// writing the default back destroys it. An absent key is safe to default.
2526+///
2527+/// The old `parse_kv` conflated read errors with absence, which meant a
2528+/// background wake while the device was locked (where `completeUnlessOpen`
2529+/// refuses to open existing files) would load an empty model, and the next
2530+/// `persist_*` would write that emptiness back over the real data.
2531+fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Result<Option<T>, ()> {
2532+ match res {
2533+ Ok(Some(bytes)) => serde_json::from_slice(&bytes)
2534+ .map(Some)
2535+ .map_err(|_| ()),
2536+ Ok(None) => Ok(None),
2537+ Err(_) => Err(()),
2538+ }
2539+}
2540+
2541+#[cfg(test)]
2542+mod tests {
2543+ use super::*;
2544+ use crate::crypto::Identity;
2545+ use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
2546+
2547+ fn with_identity() -> Model {
2548+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
2549+ Model {
2550+ secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
2551+ my_pubkey: id.public_key_hex(),
2552+ ..Default::default()
2553+ }
2554+ }
2555+
2556+ fn peer_hex(seed: u8) -> String {
2557+ Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
2558+ }
2559+
2560+ /// `Command::event` *queues* an event; it does not run it. The runtime drains
2561+ /// that queue back into `update`, so a test that stops at the first `update`
2562+ /// only ever sees half of what an event actually does — `Connect` would look
2563+ /// like it never authenticates, and the poll watchdog like it never re-polls.
2564+ /// Drain it the way the runtime does.
2565+ fn drive(app: &Skrepka, cmd: &mut Command<Effect, Event>, model: &mut Model) {
2566+ for event in cmd.events().collect::<Vec<_>>() {
2567+ let mut next = app.update(event, model);
2568+ drive(app, &mut next, model);
2569+ }
2570+ }
2571+
2572+ /// `update` + drain, i.e. what the shell actually observes.
2573+ fn dispatch(app: &Skrepka, event: Event, model: &mut Model) {
2574+ let mut cmd = app.update(event, model);
2575+ drive(app, &mut cmd, model);
2576+ }
2577+
2578+ /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
2579+ fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2580+ let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
2581+ let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
2582+ let json = protocol::serialize_payload(payload, ts);
2583+ let blob = crate::crypto::encrypt(
2584+ &mut rand_core::OsRng,
2585+ &sender,
2586+ &recipient.public_key(),
2587+ json.as_bytes(),
2588+ )
2589+ .unwrap();
2590+ PollResp {
2591+ events: vec![PollEvent {
2592+ encrypted_blob: hex::encode(blob),
2593+ }],
2594+ cursor: 1,
2595+ }
2596+ }
2597+
2598+ fn text(id: &str, body: &str) -> Payload {
2599+ Payload::Text {
2600+ id: id.to_string(),
2601+ body: body.to_string(),
2602+ }
2603+ }
2604+
2605+ fn queued(recipient: &str, body: &str) -> OutboxItem {
2606+ OutboxItem::new(
2607+ recipient.to_string(),
2608+ Arc::new(protocol::serialize_payload(&text(body, body), 1)),
2609+ )
2610+ }
2611+
2612+ /// The `/messages` batches a command actually puts on the wire. Reading them
2613+ /// off the effect is the only way to see what was *sent*, as opposed to what
2614+ /// the model thinks it sent.
2615+ fn sent_batches(cmd: &mut Command<Effect, Event>) -> Vec<Vec<Envelope>> {
2616+ #[derive(Deserialize)]
2617+ struct Sent {
2618+ messages: Vec<Envelope>,
2619+ }
2620+ cmd.effects()
2621+ .filter_map(|e| match e {
2622+ Effect::Http(req) if req.operation.url.ends_with("/messages") => {
2623+ serde_json::from_slice::<Sent>(&req.operation.body)
2624+ .ok()
2625+ .map(|b| b.messages)
2626+ }
2627+ _ => None,
2628+ })
2629+ .collect()
2630+ }
2631+
2632+ fn ok_response(status: u16) -> crux_http::Response<Vec<u8>> {
2633+ let status = crux_http::http::StatusCode::try_from(status).unwrap();
2634+ crux_http::testing::ResponseBuilder::with_status(status)
2635+ .body(Vec::new())
2636+ .build()
2637+ }
2638+
2639+ /// The same, plus one header — the only thing a relay can say to us beyond a
2640+ /// status code, and `Retry-After` is the one we act on.
2641+ fn response_with_header(status: u16, name: &str, value: &str) -> crux_http::Response<Vec<u8>> {
2642+ let status = crux_http::http::StatusCode::try_from(status).unwrap();
2643+ crux_http::testing::ResponseBuilder::with_status(status)
2644+ .header(name, value)
2645+ .body(Vec::new())
2646+ .build()
2647+ }
2648+
2649+ /// The delays a command actually asks the shell to wait, in milliseconds.
2650+ /// Asserting on the model alone cannot see these — a backoff lives entirely in
2651+ /// the effect.
2652+ fn scheduled_delays(cmd: &mut Command<Effect, Event>) -> Vec<u64> {
2653+ cmd.effects()
2654+ .filter_map(|e| match e {
2655+ Effect::Time(req) => match req.operation {
2656+ crux_time::TimeRequest::NotifyAfter { duration, .. } => {
2657+ Some(Duration::from(duration).as_millis() as u64)
2658+ }
2659+ _ => None,
2660+ },
2661+ _ => None,
2662+ })
2663+ .collect()
2664+ }
2665+
2666+ /// One full send round-trip: flush the outbox, then answer with `status`.
2667+ fn send_and_answer(app: &Skrepka, m: &mut Model, status: u16) {
2668+ let _ = app.update(Event::StartFlush, m);
2669+ let gen = m.flush_gen;
2670+ let _ = app.update(Event::SendResult(gen, Ok(ok_response(status))), m);
2671+ }
2672+
2673+ // -----------------------------------------------------------------------
2674+ // outbox: retry budget and TTL
2675+ // -----------------------------------------------------------------------
2676+
2677+ /// The outbox is a strict FIFO, so an item that can never be delivered used to
2678+ /// block every message queued behind it — forever.
2679+ #[test]
2680+ fn an_undeliverable_item_is_dropped_once_it_runs_out_of_retries() {
2681+ let app = Skrepka;
2682+ let mut m = with_identity();
2683+ m.token = Some("t".into());
2684+ let stuck = peer_hex(9);
2685+ let behind = peer_hex(11);
2686+ m.outbox.push_back(queued(&stuck, "wedged"));
2687+ m.outbox.push_back(queued(&behind, "stranded"));
2688+
2689+ // The relay keeps 500-ing on the head of the queue.
2690+ for i in 1..MAX_OUTBOX_RETRIES {
2691+ send_and_answer(&app, &mut m, 503);
2692+ assert_eq!(m.outbox.len(), 2, "still queued after {i} failure(s)");
2693+ assert_eq!(m.outbox[0].retries, i, "and its budget is being spent");
2694+ }
2695+
2696+ // The last one exhausts the budget: the head goes, and what was stuck
2697+ // behind it goes out.
2698+ let _ = app.update(Event::StartFlush, &mut m);
2699+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(503))), &mut m);
2700+
2701+ assert_eq!(m.outbox.len(), 1, "the undeliverable item is dropped");
2702+ assert_eq!(m.outbox[0].recipient, behind, "and the queue moves on");
2703+ assert_eq!(
2704+ sent_batches(&mut cmd).len(),
2705+ 1,
2706+ "the message behind it is sent immediately, not left for the next flush"
2707+ );
2708+ let err = m.error.expect("the user is told the message was given up on");
2709+ assert!(err.contains("gave up sending 1"), "got: {err}");
2710+ }
2711+
2712+ /// The counter alone is not enough: retries only accrue when a send is
2713+ /// attempted, so a client that goes online once a day would take a fortnight to
2714+ /// spend a ten-retry budget. The TTL bounds it in wall-clock time instead.
2715+ #[test]
2716+ fn an_item_older_than_the_ttl_is_dropped_on_its_next_failure() {
2717+ let app = Skrepka;
2718+ let mut m = with_identity();
2719+ m.token = Some("t".into());
2720+ let peer = peer_hex(9);
2721+
2722+ let mut old = queued(&peer, "ancient");
2723+ old.first_attempt = now_ms() - OUTBOX_TTL_MS - 1;
2724+ old.retries = 1; // nowhere near the retry cap
2725+ m.outbox.push_back(old);
2726+
2727+ send_and_answer(&app, &mut m, 503);
2728+
2729+ assert!(m.outbox.is_empty(), "the TTL alone is enough to drop it");
2730+ assert!(m.error.is_some_and(|e| e.contains("gave up sending")));
2731+ }
2732+
2733+ /// An item queued during a week offline must not be dropped the moment the
2734+ /// network returns: the TTL runs from the first *attempt*, not from when the
2735+ /// message was written. An outbox blob from a build with no `first_attempt`
2736+ /// field deserializes to 0, which must mean "never attempted", not "epoch".
2737+ #[test]
2738+ fn an_item_that_was_never_attempted_has_no_ttl_to_outlive() {
2739+ let item = OutboxItem::new(peer_hex(9), Arc::new("{}".into()));
2740+ assert_eq!(item.first_attempt, 0);
2741+ assert!(
2742+ !item.is_expired(now_ms()),
2743+ "an unsent item must not age out against the epoch"
2744+ );
2745+ }
2746+
2747+ /// The TTL clock has to survive a relaunch, or a permanently-stuck item is
2748+ /// handed a fresh 24 hours on every launch and the outbox wedges forever again.
2749+ #[test]
2750+ fn the_first_attempt_stamp_is_persisted_when_the_item_goes_out() {
2751+ let app = Skrepka;
2752+ let mut m = with_identity();
2753+ m.token = Some("t".into());
2754+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2755+ let before = now_ms();
2756+
2757+ let mut cmd = app.update(Event::StartFlush, &mut m);
2758+
2759+ assert!(m.outbox[0].first_attempt >= before, "the item is stamped");
2760+ // ...and the stamp is written to the `outbox` key, not just to memory.
2761+ let wrote_outbox = cmd.effects().any(|e| {
2762+ matches!(e, Effect::KeyValue(req)
2763+ if matches!(&req.operation, crux_kv::KeyValueOperation::Set { key, .. } if key == K_OUTBOX))
2764+ });
2765+ assert!(wrote_outbox, "the stamped outbox is persisted");
2766+ }
2767+
2768+ /// A transient failure below the budget changes nothing but the counter — the
2769+ /// message stays queued and goes out on the next flush.
2770+ #[test]
2771+ fn a_transient_failure_within_budget_keeps_the_message() {
2772+ let app = Skrepka;
2773+ let mut m = with_identity();
2774+ m.token = Some("t".into());
2775+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2776+
2777+ send_and_answer(&app, &mut m, 503);
2778+ assert_eq!(m.outbox.len(), 1);
2779+ assert_eq!(m.outbox[0].retries, 1);
2780+ assert!(m.error.is_none(), "a single 5xx is not worth shouting about");
2781+ assert!(!m.flushing, "and the guard is released so the next flush can run");
2782+
2783+ // The relay recovers; the message goes out and the item is gone.
2784+ send_and_answer(&app, &mut m, 200);
2785+ assert!(m.outbox.is_empty());
2786+ }
2787+
2788+ /// A 401 is not a delivery failure — it means our token expired. The batch must
2789+ /// not be charged a retry for it, or a long-lived client burns its whole budget
2790+ /// on routine re-auths.
2791+ #[test]
2792+ fn a_401_does_not_spend_the_retry_budget() {
2793+ let app = Skrepka;
2794+ let mut m = with_identity();
2795+ m.token = Some("t".into());
2796+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2797+
2798+ send_and_answer(&app, &mut m, 401);
2799+
2800+ assert_eq!(m.outbox.len(), 1, "the message is still queued");
2801+ assert_eq!(m.outbox[0].retries, 0, "and unpunished");
2802+ assert!(m.token.is_none(), "but the token is thrown away");
2803+ }
2804+
2805+ /// A 429 is the relay's load, not the message's fault. Charging the batch a
2806+ /// retry for it would let a busy relay drive perfectly good messages out of the
2807+ /// outbox — and the relay usually says exactly how long to wait, so wait that
2808+ /// long rather than guessing.
2809+ #[test]
2810+ fn a_429_waits_out_retry_after_without_spending_the_retry_budget() {
2811+ let app = Skrepka;
2812+ let mut m = with_identity();
2813+ m.token = Some("t".into());
2814+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2815+
2816+ let _ = app.update(Event::StartFlush, &mut m);
2817+ let mut cmd = app.update(
2818+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2819+ &mut m,
2820+ );
2821+
2822+ assert_eq!(m.outbox.len(), 1, "the message is still queued");
2823+ assert_eq!(m.outbox[0].retries, 0, "and unpunished — it did nothing wrong");
2824+ assert!(!m.flushing, "the guard is released so the retry can run");
2825+ assert_eq!(
2826+ scheduled_delays(&mut cmd),
2827+ vec![2_000],
2828+ "and we wait exactly as long as the relay asked"
2829+ );
2830+ }
2831+
2832+ /// Without the header there is nothing to honour, so fall back to our own
2833+ /// backoff rather than hammering the relay that just asked us to slow down.
2834+ ///
2835+ /// On the *send* loop's counter. `poll_retries` is reset by every page the poll
2836+ /// loop takes, so spending it here means a client that polls happily while the
2837+ /// relay throttles its sends restarts the send backoff at 3 s forever.
2838+ #[test]
2839+ fn a_429_without_header_uses_send_side_backoff() {
2840+ let app = Skrepka;
2841+ let mut m = with_identity();
2842+ m.token = Some("t".into());
2843+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2844+ // The poll loop is healthy and has just taken a page — exactly the state that
2845+ // used to reset the send backoff out from under itself.
2846+ m.poll_retries = 0;
2847+
2848+ let _ = app.update(Event::StartFlush, &mut m);
2849+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2850+
2851+ assert_eq!(m.outbox[0].retries, 0, "still not the message's fault");
2852+ assert_eq!(
2853+ scheduled_delays(&mut cmd),
2854+ vec![backoff_ms(0)],
2855+ "the first send-side backoff"
2856+ );
2857+ assert_eq!(m.send_retries, 1, "and the send loop's own counter is spent");
2858+
2859+ // A second 429 climbs, rather than restarting at 3 s.
2860+ m.flush_paused_until = 0;
2861+ let _ = app.update(Event::StartFlush, &mut m);
2862+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2863+ assert_eq!(scheduled_delays(&mut cmd), vec![backoff_ms(1)], "it climbs");
2864+ assert_eq!(m.send_retries, 2);
2865+ }
2866+
2867+ /// The `Retry-After` timer is not the only thing that fires `StartFlush` — a poll
2868+ /// page and a fresh `SendText` both do. Without a pause latched on the model the
2869+ /// wait is decorative: the next poll page walks through it and hammers the relay
2870+ /// that just asked us to stop.
2871+ #[test]
2872+ fn a_429_pauses_flush_until_the_delay_elapses() {
2873+ let app = Skrepka;
2874+ let mut m = with_identity();
2875+ m.token = Some("t".into());
2876+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2877+
2878+ let _ = app.update(Event::StartFlush, &mut m);
2879+ let _ = app.update(
2880+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2881+ &mut m,
2882+ );
2883+ assert!(
2884+ m.flush_paused_until > now_ms(),
2885+ "the relay's wait is latched on the model, not just on a timer"
2886+ );
2887+
2888+ // Anything else that wakes the send loop during the wait — a poll page, a new
2889+ // message — must find it closed.
2890+ let mut cmd = app.update(Event::StartFlush, &mut m);
2891+ assert!(
2892+ sent_batches(&mut cmd).is_empty(),
2893+ "nothing goes out while the relay has asked us to wait"
2894+ );
2895+ assert!(!m.flushing, "and the loop is left free to resume later");
2896+ assert_eq!(m.outbox.len(), 1, "the message is still queued");
2897+
2898+ // Time passes (the clock cannot be moved in a test, so retire the pause the
2899+ // way the elapsing of it would) and the timer's own `StartFlush` lands.
2900+ m.flush_paused_until = 0;
2901+ let mut cmd = app.update(Event::StartFlush, &mut m);
2902+ assert_eq!(
2903+ sent_batches(&mut cmd).len(),
2904+ 1,
2905+ "and once the wait is over the batch goes out"
2906+ );
2907+ }
2908+
2909+ /// A send the relay accepts means the throttle is off. Keeping the counter would
2910+ /// make the next 429 — hours later, under unrelated load — resume at whatever
2911+ /// height the last one climbed to.
2912+ #[test]
2913+ fn a_successful_send_clears_the_send_side_backoff() {
2914+ let app = Skrepka;
2915+ let mut m = with_identity();
2916+ m.token = Some("t".into());
2917+ m.send_retries = 4;
2918+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2919+
2920+ send_and_answer(&app, &mut m, 200);
2921+
2922+ assert_eq!(m.send_retries, 0, "the relay is taking our sends again");
2923+ }
2924+
2925+ /// `Retry-After` is a number a relay chooses and we obey. Unbounded, one of
2926+ /// them parks the outbox for thirty years — with nothing on screen to say why.
2927+ #[test]
2928+ fn an_absurd_retry_after_is_clamped() {
2929+ let app = Skrepka;
2930+ let mut m = with_identity();
2931+ m.token = Some("t".into());
2932+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2933+
2934+ let _ = app.update(Event::StartFlush, &mut m);
2935+ let mut cmd = app.update(
2936+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "999999999"))),
2937+ &mut m,
2938+ );
2939+
2940+ assert_eq!(
2941+ scheduled_delays(&mut cmd),
2942+ vec![MAX_RETRY_AFTER_MS],
2943+ "we wait, but only as long as we are willing to"
2944+ );
2945+ }
2946+
2947+ // -----------------------------------------------------------------------
2948+ // outbox: batching
2949+ // -----------------------------------------------------------------------
2950+
2951+ /// Sending one message per request meant a round-trip per *ack* too, so
2952+ /// catching up on a hundred messages cost a hundred requests.
2953+ #[test]
2954+ fn consecutive_items_for_one_recipient_go_out_in_a_single_request() {
2955+ let app = Skrepka;
2956+ let mut m = with_identity();
2957+ m.token = Some("t".into());
2958+ let peer = peer_hex(9);
2959+ for i in 0..5 {
2960+ m.outbox.push_back(queued(&peer, &format!("m{i}")));
2961+ }
2962+
2963+ let mut cmd = app.update(Event::StartFlush, &mut m);
2964+
2965+ let batches = sent_batches(&mut cmd);
2966+ assert_eq!(batches.len(), 1, "one request, not five");
2967+ assert_eq!(batches[0].len(), 5, "carrying every message");
2968+ assert!(batches[0].iter().all(|e| e.to == peer));
2969+
2970+ // ...and its success clears the whole batch, not just the head.
2971+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2972+ assert!(m.outbox.is_empty());
2973+ }
2974+
2975+ /// The batch stops at the first recipient change: a batch is delivered as a
2976+ /// unit, and hoisting a later item for the same peer over an earlier one for a
2977+ /// different peer would let one busy conversation starve the rest of the queue.
2978+ #[test]
2979+ fn a_batch_stops_at_the_first_recipient_change() {
2980+ let app = Skrepka;
2981+ let mut m = with_identity();
2982+ m.token = Some("t".into());
2983+ let a = peer_hex(9);
2984+ let b = peer_hex(11);
2985+ m.outbox.push_back(queued(&a, "a1"));
2986+ m.outbox.push_back(queued(&a, "a2"));
2987+ m.outbox.push_back(queued(&b, "b1"));
2988+ m.outbox.push_back(queued(&a, "a3"));
2989+
2990+ let mut cmd = app.update(Event::StartFlush, &mut m);
2991+ let batches = sent_batches(&mut cmd);
2992+ assert_eq!(batches.len(), 1);
2993+ assert_eq!(batches[0].len(), 2, "only the run at the head");
2994+ assert!(batches[0].iter().all(|e| e.to == a));
2995+
2996+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2997+ assert_eq!(m.outbox.len(), 2);
2998+ let batches = sent_batches(&mut cmd);
2999+ assert_eq!(batches[0].len(), 1, "then b's single message");
3000+ assert_eq!(batches[0][0].to, b);
3001+ }
3002+
3003+ /// The relay answers `413 batch_too_large` past a hundred (PROTOCOL.md §7) —
3004+ /// which we would read as a transient failure and retry forever.
3005+ #[test]
3006+ fn a_batch_is_capped_at_the_relays_limit() {
3007+ let app = Skrepka;
3008+ let mut m = with_identity();
3009+ m.token = Some("t".into());
3010+ let peer = peer_hex(9);
3011+ for i in 0..(MAX_SEND_BATCH + 20) {
3012+ m.outbox.push_back(queued(&peer, &format!("m{i}")));
3013+ }
3014+
3015+ let mut cmd = app.update(Event::StartFlush, &mut m);
3016+ let batches = sent_batches(&mut cmd);
3017+ assert_eq!(batches[0].len(), MAX_SEND_BATCH);
3018+
3019+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3020+ assert_eq!(m.outbox.len(), 20, "the rest stays queued for the next batch");
3021+ }
3022+
3023+ /// A hundred profile broadcasts each carrying a 64 KiB photo would build a
3024+ /// request far past the body-size cap a relay has by default — and the `413`
3025+ /// that came back would look transient, so we would retry it forever.
3026+ #[test]
3027+ fn a_batch_is_capped_by_body_size_as_well_as_count() {
3028+ let app = Skrepka;
3029+ let mut m = with_identity();
3030+ m.token = Some("t".into());
3031+ let peer = peer_hex(9);
3032+ // A body at the cap, and incompressible — `crypto::encrypt` deflates the
3033+ // plaintext, so a run of one character would ride out to nearly nothing and
3034+ // prove no cap at all.
3035+ let mut seed = 1u64;
3036+ let big: String = std::iter::repeat_with(|| {
3037+ seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3038+ char::from(b'a' + (seed >> 33) as u8 % 26)
3039+ })
3040+ .take(protocol::MAX_BODY_LEN)
3041+ .collect();
3042+ for i in 0..MAX_SEND_BATCH {
3043+ m.outbox.push_back(OutboxItem::new(
3044+ peer.clone(),
3045+ Arc::new(protocol::serialize_payload(&text(&format!("m{i}"), &big), 1)),
3046+ ));
3047+ }
3048+
3049+ let mut cmd = app.update(Event::StartFlush, &mut m);
3050+ let batches = sent_batches(&mut cmd);
3051+ let body: usize = batches[0].iter().map(|e| e.encrypted_blob.len()).sum();
3052+
3053+ assert!(batches[0].len() < MAX_SEND_BATCH, "the count cap is not the binding one");
3054+ assert!(!batches[0].is_empty(), "but something still goes out");
3055+ assert!(body <= MAX_SEND_BATCH_BYTES, "the body stays inside the budget");
3056+ }
3057+
3058+ /// `MAX_SEND_BATCH_BYTES` is a *guess* at a cap the protocol never publishes, and
3059+ /// a relay configured below it answers `413`. Read as a transient failure, that
3060+ /// `413` rebuilds the identical batch, is rejected identically, and repeats until
3061+ /// every item has burned its retry budget — the outbox drains without a single
3062+ /// message being delivered. So a `413` measures the cap instead of fighting it.
3063+ #[test]
3064+ fn a_413_halves_the_batch_budget_and_retries_smaller() {
3065+ let app = Skrepka;
3066+ let mut m = with_identity();
3067+ m.token = Some("t".into());
3068+ let peer = peer_hex(9);
3069+ for i in 0..5 {
3070+ m.outbox.push_back(queued(&peer, &format!("m{i}")));
3071+ }
3072+
3073+ // Nothing is known about this relay yet, so all five go out at once.
3074+ let mut cmd = app.update(Event::StartFlush, &mut m);
3075+ let first = sent_batches(&mut cmd);
3076+ assert_eq!(first[0].len(), 5, "the whole run goes out on the first attempt");
3077+ assert!(m.send_batch_budget.is_none(), "and we have assumed nothing");
3078+ let sent_bytes: usize = first[0].iter().map(|e| e.encrypted_blob.len()).sum();
3079+
3080+ // ...and the relay says that was too big.
3081+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(413))), &mut m);
3082+
3083+ assert_eq!(
3084+ m.send_batch_budget,
3085+ Some(sent_bytes / 2),
3086+ "the budget is halved against what actually failed, not against the constant"
3087+ );
3088+ assert_eq!(m.outbox.len(), 5, "a 413 delivered nothing, so nothing is popped");
3089+ assert!(
3090+ m.outbox.iter().all(|i| i.retries == 0),
3091+ "and the items are not charged for the relay being smaller than we guessed"
3092+ );
3093+
3094+ // The retry goes out at once, and it is smaller.
3095+ let second = sent_batches(&mut cmd);
3096+ assert_eq!(second.len(), 1, "retried immediately, not left for the next flush");
3097+ let shrunk = second[0].len();
3098+ assert!(shrunk < 5, "with a smaller batch, got {shrunk} items");
3099+ assert!(shrunk > 0, "but not an empty one");
3100+
3101+ // The relay takes the smaller batch, and exactly it is popped.
3102+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3103+ assert_eq!(m.outbox.len(), 5 - shrunk, "exactly the batch that landed is gone");
3104+ assert_eq!(
3105+ m.send_batch_budget,
3106+ Some(sent_bytes / 2),
3107+ "and what we learned about the relay survives the success — its cap did not grow"
3108+ );
3109+ }
3110+
3111+ /// A batch of one cannot be split, and `flush_next` takes the first item whatever
3112+ /// it weighs — so halving the budget around a lone oversized item would rebuild
3113+ /// the same one-item batch forever, at full speed, with no retry ever charged.
3114+ /// It has to fall through to the retry budget, which is what eventually drops it.
3115+ #[test]
3116+ fn a_413_on_a_single_item_ages_out_instead_of_spinning() {
3117+ let app = Skrepka;
3118+ let mut m = with_identity();
3119+ m.token = Some("t".into());
3120+ m.outbox.push_back(queued(&peer_hex(9), "bigger than this relay allows"));
3121+
3122+ for i in 1..MAX_OUTBOX_RETRIES {
3123+ send_and_answer(&app, &mut m, 413);
3124+ assert_eq!(m.outbox.len(), 1, "still queued after {i} rejection(s)");
3125+ assert_eq!(m.outbox[0].retries, i, "and charged for each of them");
3126+ }
3127+ assert!(
3128+ m.send_batch_budget.is_none(),
3129+ "an unsplittable batch teaches us nothing about the cap"
3130+ );
3131+
3132+ send_and_answer(&app, &mut m, 413);
3133+ assert!(m.outbox.is_empty(), "the item that can never be sent is dropped");
3134+ assert!(
3135+ m.error.is_some_and(|e| e.contains("gave up sending")),
3136+ "and the user is told, rather than the message quietly vanishing"
3137+ );
3138+ }
3139+
3140+ /// What we learned is about *that relay*. A new one is entitled to a bigger cap,
3141+ /// and it will never send the `413` that would correct a budget carried over.
3142+ #[test]
3143+ fn changing_relay_forgets_the_learned_batch_budget() {
3144+ let app = Skrepka;
3145+ let mut m = with_identity();
3146+ m.send_batch_budget = Some(1024);
3147+
3148+ let _ = app.update(Event::SetServerUrl("https://other.example".into()), &mut m);
3149+
3150+ assert!(m.send_batch_budget.is_none());
3151+ }
3152+
3153+ /// An unusable key at the head still has to be discarded, batching or not —
3154+ /// and the discard must not take the rest of the queue with it.
3155+ #[test]
3156+ fn an_unsendable_head_is_dropped_and_the_batch_behind_it_still_goes_out() {
3157+ let app = Skrepka;
3158+ let mut m = with_identity();
3159+ m.token = Some("t".into());
3160+ let good = peer_hex(9);
3161+ m.outbox.push_back(queued(&hex::encode([0xabu8; 32]), "doomed"));
3162+ m.outbox.push_back(queued(&good, "fine"));
3163+ m.outbox.push_back(queued(&good, "also fine"));
3164+
3165+ let mut cmd = app.update(Event::StartFlush, &mut m);
3166+
3167+ assert_eq!(m.outbox.len(), 2, "only the unsendable item is dropped");
3168+ let batches = sent_batches(&mut cmd);
3169+ assert_eq!(batches[0].len(), 2, "and the batch behind it goes out whole");
3170+ assert!(m.error.is_some_and(|e| e.contains("invalid key")));
3171+ }
3172+
3173+ // -----------------------------------------------------------------------
3174+ // outbox: profile supersede
3175+ // -----------------------------------------------------------------------
3176+
3177+ /// A profile is state, not an event: only the newest one means anything. Five
3178+ /// quick edits used to queue five payloads per contact — each with the photo.
3179+ #[test]
3180+ fn repeated_profile_edits_supersede_rather_than_pile_up() {
3181+ let app = Skrepka;
3182+ let mut m = with_identity();
3183+ let a = peer_hex(9);
3184+ let b = peer_hex(11);
3185+ for peer in [&a, &b] {
3186+ m.contacts
3187+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3188+ }
3189+
3190+ for name in ["One", "Two", "Three"] {
3191+ let _ = app.update(
3192+ Event::SaveProfile {
3193+ display_name: name.into(),
3194+ bio: String::new(),
3195+ photo: None,
3196+ },
3197+ &mut m,
3198+ );
3199+ }
3200+
3201+ assert_eq!(m.outbox.len(), 2, "one pending profile per contact, not three");
3202+ for item in &m.outbox {
3203+ assert!(item.is_profile());
3204+ let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
3205+ match parsed.payload {
3206+ Payload::Profile { display_name, .. } => {
3207+ assert_eq!(display_name, Some("Three".to_string()), "and it is the latest one");
3208+ }
3209+ _ => panic!("expected a profile"),
3210+ }
3211+ }
3212+ }
3213+
3214+ /// Superseding must not reach into a payload that is already on the wire: its
3215+ /// ciphertext is built and gone, and `SendResult` pops it on success — so a
3216+ /// profile swapped in now would be dropped having never been sent.
3217+ #[test]
3218+ fn a_profile_already_in_flight_is_not_rewritten_under_the_send() {
3219+ let app = Skrepka;
3220+ let mut m = with_identity();
3221+ m.token = Some("t".into());
3222+ let peer = peer_hex(9);
3223+ m.contacts
3224+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3225+
3226+ let _ = app.update(
3227+ Event::SaveProfile {
3228+ display_name: "Old".into(),
3229+ bio: String::new(),
3230+ photo: None,
3231+ },
3232+ &mut m,
3233+ );
3234+ // It goes out and is still in flight.
3235+ let _ = app.update(Event::StartFlush, &mut m);
3236+ assert!(m.flushing);
3237+
3238+ // A second edit lands mid-send. It must queue a *new* item rather than
3239+ // rewrite the one whose ciphertext is already gone.
3240+ let _ = app.update(
3241+ Event::SaveProfile {
3242+ display_name: "New".into(),
3243+ bio: String::new(),
3244+ photo: None,
3245+ },
3246+ &mut m,
3247+ );
3248+ assert_eq!(m.outbox.len(), 2, "the in-flight item is left alone");
3249+
3250+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3251+ assert_eq!(m.outbox.len(), 1, "the sent one is popped");
3252+ let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
3253+ match parsed.payload {
3254+ Payload::Profile { display_name, .. } => assert_eq!(
3255+ display_name, Some("New".to_string()),
3256+ "and the newer profile survives to be sent"
3257+ ),
3258+ _ => panic!("expected a profile"),
3259+ }
3260+ }
3261+
3262+ // -----------------------------------------------------------------------
3263+ // in-flight bookkeeping
3264+ // -----------------------------------------------------------------------
3265+
3266+ /// `SendResult` pops by count off the head of the outbox. If the items it sent
3267+ /// were removed from under it — the user blocked the peer mid-send — popping by
3268+ /// count anyway takes whatever slid into their place: another peer's mail.
3269+ #[test]
3270+ fn blocking_the_peer_mid_send_does_not_pop_someone_elses_messages() {
3271+ let app = Skrepka;
3272+ let mut m = with_identity();
3273+ m.token = Some("t".into());
3274+ let enemy = peer_hex(9);
3275+ let friend = peer_hex(11);
3276+ m.contacts
3277+ .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
3278+ m.outbox.push_back(queued(&enemy, "e1"));
3279+ m.outbox.push_back(queued(&enemy, "e2"));
3280+ m.outbox.push_back(queued(&friend, "f1"));
3281+
3282+ let _ = app.update(Event::StartFlush, &mut m);
3283+ assert_eq!(m.in_flight.as_ref().unwrap().count, 2);
3284+
3285+ // Blocked mid-flight: the two items in the air are dropped from the queue.
3286+ let _ = app.update(
3287+ Event::SetBlocked {
3288+ peer: enemy.clone(),
3289+ blocked: true,
3290+ },
3291+ &mut m,
3292+ );
3293+ assert_eq!(m.outbox.len(), 1);
3294+
3295+ // The send lands. It must pop nothing.
3296+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3297+ assert_eq!(m.outbox.len(), 1, "the friend's message is untouched");
3298+ assert_eq!(m.outbox[0].recipient, friend);
3299+ }
3300+
3301+ // -----------------------------------------------------------------------
3302+ // delete contact
3303+ // -----------------------------------------------------------------------
3304+
3305+ /// Blocking only silences a peer; the entry stayed in the list forever.
3306+ #[test]
3307+ fn deleting_a_contact_forgets_the_contact_the_history_and_the_queue() {
3308+ let app = Skrepka;
3309+ let mut m = with_identity();
3310+ let peer = peer_hex(9);
3311+ let other = peer_hex(11);
3312+ for p in [&peer, &other] {
3313+ m.contacts
3314+ .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3315+ m.messages.insert(
3316+ p.clone(),
3317+ vec![StoredMessage {
3318+ id: "m1".into(),
3319+ body: "hi".into(),
3320+ ts: 1,
3321+ outgoing: false,
3322+ delivered: false,
3323+ }],
3324+ );
3325+ m.outbox.push_back(queued(p, "queued"));
3326+ }
3327+ m.active_peer = Some(peer.clone());
3328+ m.page = Page::Chat;
3329+
3330+ let mut cmd = app.update(Event::DeleteContact { peer: peer.clone() }, &mut m);
3331+
3332+ assert!(!m.contacts.contains_key(&peer));
3333+ assert!(!m.messages.contains_key(&peer));
3334+ assert!(
3335+ m.outbox.iter().all(|i| i.recipient != peer),
3336+ "nothing more goes to a peer we have forgotten"
3337+ );
3338+ assert_eq!(m.page, Page::Conversations, "the open chat is closed");
3339+ assert!(m.active_peer.is_none());
3340+
3341+ // Everyone else is untouched.
3342+ assert!(m.contacts.contains_key(&other));
3343+ assert_eq!(m.messages[&other].len(), 1);
3344+ assert_eq!(m.outbox.len(), 1);
3345+
3346+ // The history blob is *deleted*, not left behind holding what the user just
3347+ // asked us to forget.
3348+ let deleted = cmd.effects().any(|e| {
3349+ matches!(e, Effect::KeyValue(req)
3350+ if matches!(&req.operation, crux_kv::KeyValueOperation::Delete { key } if key == &k_messages(&peer)))
3351+ });
3352+ assert!(deleted, "the messages:<peer> key is removed");
3353+ }
3354+
3355+ /// Deleting a peer we are not looking at must not throw the user out of the
3356+ /// chat they *are* looking at.
3357+ #[test]
3358+ fn deleting_another_contact_leaves_the_open_chat_alone() {
3359+ let app = Skrepka;
3360+ let mut m = with_identity();
3361+ let open = peer_hex(9);
3362+ let doomed = peer_hex(11);
3363+ for p in [&open, &doomed] {
3364+ m.contacts
3365+ .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3366+ }
3367+ m.active_peer = Some(open.clone());
3368+ m.page = Page::Chat;
3369+
3370+ let _ = app.update(Event::DeleteContact { peer: doomed }, &mut m);
3371+
3372+ assert_eq!(m.page, Page::Chat);
3373+ assert_eq!(m.active_peer.as_deref(), Some(open.as_str()));
3374+ }
3375+
3376+ // -----------------------------------------------------------------------
3377+ // watermark clamping
3378+ // -----------------------------------------------------------------------
3379+
3380+ /// An ack at `ts = i64::MAX` used to park `last_ack_ts` a minute into the
3381+ /// future — and every honest ack that followed, the ones that actually mark our
3382+ /// messages delivered, read as stale and was dropped. A peer could silence its
3383+ /// own delivery receipts forever with one payload.
3384+ #[test]
3385+ fn a_far_future_ack_cannot_suppress_the_acks_that_follow_it() {
3386+ let app = Skrepka;
3387+ let mut m = with_identity();
3388+ let peer = peer_hex(9);
3389+ m.contacts
3390+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3391+ m.messages.insert(
3392+ peer.clone(),
3393+ vec![StoredMessage {
3394+ id: "m1".into(),
3395+ body: "hi".into(),
3396+ ts: 1,
3397+ outgoing: true,
3398+ delivered: false,
3399+ }],
3400+ );
3401+
3402+ // The poisoned ack: acks nothing we sent, but sets the watermark.
3403+ let ack = Payload::DeliveryAck {
3404+ ack_ids: vec!["nothing".into()],
3405+ };
3406+ let page = page_from(9, &m, &ack, i64::MAX);
3407+ let _ = app.ingest_poll(&mut m, page);
3408+ assert!(
3409+ m.contacts[&peer].last_ack_ts <= now_ms(),
3410+ "the watermark cannot be pushed into the future"
3411+ );
3412+
3413+ // A perfectly ordinary ack, sent now. It must still land.
3414+ let ack = Payload::DeliveryAck {
3415+ ack_ids: vec!["m1".into()],
3416+ };
3417+ let page = page_from(9, &m, &ack, now_ms());
3418+ let _ = app.ingest_poll(&mut m, page);
3419+ assert!(
3420+ m.messages[&peer][0].delivered,
3421+ "an honest ack after a far-future one must still be honoured"
3422+ );
3423+ }
3424+
3425+ /// The same trick against `last_profile_ts`: a profile at `ts = i64::MAX` would
3426+ /// freeze the peer's own entry, since every honest update they sent afterwards
3427+ /// would read as a stale replay of itself.
3428+ #[test]
3429+ fn a_far_future_profile_cannot_freeze_the_peers_own_profile() {
3430+ let app = Skrepka;
3431+ let mut m = with_identity();
3432+ let peer = peer_hex(9);
3433+ m.contacts
3434+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3435+
3436+ let profile = |name: &str| Payload::Profile {
3437+ display_name: Some(name.to_string()),
3438+ bio: Some(String::new()),
3439+ photo: None,
3440+ };
3441+ let page = page_from(9, &m, &profile("Far"), i64::MAX);
3442+ let _ = app.ingest_poll(&mut m, page);
3443+ let first_ts = m.contacts[&peer].last_profile_ts;
3444+ assert!(first_ts <= now_ms());
3445+
3446+ // A later profile at a greater ts must still apply — the clamping
3447+ // is what stops the far-future one from freezing the peer.
3448+ let page = page_from(9, &m, &profile("Now"), first_ts + 1);
3449+ let _ = app.ingest_poll(&mut m, page);
3450+ assert_eq!(
3451+ m.contacts[&peer].display_name, "Now",
3452+ "a later, honest profile must still apply"
3453+ );
3454+ }
3455+
3456+ // -----------------------------------------------------------------------
3457+ // conversation ordering
3458+ // -----------------------------------------------------------------------
3459+
3460+ /// The conversation is held in `ts` order rather than re-sorted per arrival.
3461+ /// `trim_history` ages out the *front*, so an out-of-order insert does not just
3462+ /// render wrong — it throws away the wrong message.
3463+ #[test]
3464+ fn messages_are_inserted_in_timestamp_order() {
3465+ let app = Skrepka;
3466+ let mut m = with_identity();
3467+ let peer = peer_hex(9);
3468+
3469+ // Arrive out of order, including a tie.
3470+ for (i, ts) in [300i64, 100, 200, 200].into_iter().enumerate() {
3471+ let page = page_from(9, &m, &text(&format!("m{i}"), "x"), ts);
3472+ let _ = app.ingest_poll(&mut m, page);
3473+ }
3474+ // ...and an outgoing message, which used to be appended blindly.
3475+ m.active_peer = Some(peer.clone());
3476+ m.compose = "mine".into();
3477+ let _ = app.update(Event::SendText, &mut m);
3478+
3479+ let convo = &m.messages[&peer];
3480+ assert!(
3481+ convo.windows(2).all(|w| w[0].ts <= w[1].ts),
3482+ "the conversation is sorted: {:?}",
3483+ convo.iter().map(|s| s.ts).collect::<Vec<_>>()
3484+ );
3485+ // Ties keep arrival order, exactly as the stable sort used to give.
3486+ let tied: Vec<&str> = convo
3487+ .iter()
3488+ .filter(|s| s.ts == 200)
3489+ .map(|s| s.id.as_str())
3490+ .collect();
3491+ assert_eq!(tied, vec!["m2", "m3"]);
3492+ }
3493+
3494+ /// PROTOCOL.md §4: a blocked peer's messages are not shown — and acking them
3495+ /// would tell the blocked peer we are online and reading.
3496+ #[test]
3497+ fn a_blocked_senders_message_is_neither_stored_nor_acked() {
3498+ let app = Skrepka;
3499+ let mut m = with_identity();
3500+ let peer = peer_hex(9);
3501+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
3502+ contact.blocked = true;
3503+ m.contacts.insert(peer.clone(), contact);
3504+
3505+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
3506+ let _ = app.ingest_poll(&mut m, page);
3507+
3508+ assert!(
3509+ m.messages.get(&peer).is_none_or(Vec::is_empty),
3510+ "a blocked peer's message must not be stored"
3511+ );
3512+ assert!(m.outbox.is_empty(), "no delivery.ack goes back to a blocked peer");
3513+ // ...and nothing to render in the chat, even if it is opened.
3514+ m.active_peer = Some(peer);
3515+ m.page = Page::Chat;
3516+ assert!(app.view(&m).messages.is_empty());
3517+ }
3518+
3519+ /// The same page, unblocked: the control that proves the gate is the block
3520+ /// flag and not something else in the ingest path.
3521+ fn ingest_one_text_from_an_unblocked_peer() -> (Model, String) {
3522+ let app = Skrepka;
3523+ let mut m = with_identity();
3524+ let peer = peer_hex(9);
3525+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
3526+ let _ = app.ingest_poll(&mut m, page);
3527+ (m, peer)
3528+ }
3529+
3530+ #[test]
3531+ fn an_unblocked_senders_message_is_stored_and_acked() {
3532+ let (m, peer) = ingest_one_text_from_an_unblocked_peer();
3533+ assert_eq!(m.messages[&peer].len(), 1);
3534+ assert_eq!(m.messages[&peer][0].body, "hi");
3535+ // D6-app: A profile broadcast is queued alongside the delivery.ack
3536+ // so the new contact sees our display name and avatar.
3537+ assert_eq!(m.outbox.len(), 2, "one delivery.ack + one profile queued");
3538+ assert_eq!(m.outbox[0].recipient, peer);
3539+ assert_eq!(m.outbox[1].recipient, peer);
3540+ }
3541+
3542+ /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
3543+ /// conversation list forever, and render with an absurd timestamp.
3544+ #[test]
3545+ fn a_far_future_timestamp_is_clamped_to_now() {
3546+ let app = Skrepka;
3547+ let mut m = with_identity();
3548+ let peer = peer_hex(9);
3549+
3550+ let page = page_from(9, &m, &text("m1", "hi"), i64::MAX);
3551+ let before = now_ms();
3552+ let _ = app.ingest_poll(&mut m, page);
3553+
3554+ let ts = m.messages[&peer][0].ts;
3555+ assert!(
3556+ ts <= now_ms() + MAX_FUTURE_SKEW_MS,
3557+ "ts {ts} must be clamped into the skew window"
3558+ );
3559+ assert!(ts >= before, "and clamped to now, not to some past value");
3560+ // The clamped value is what the shell sorts and renders by.
3561+ assert_eq!(app.view(&m).contacts[0].last_ts, ts);
3562+ }
3563+
3564+ /// Honest clock skew inside the tolerance is preserved as-is.
3565+ #[test]
3566+ fn a_slightly_future_timestamp_is_kept() {
3567+ let app = Skrepka;
3568+ let mut m = with_identity();
3569+ let peer = peer_hex(9);
3570+ let ts = now_ms() + 5_000;
3571+
3572+ let page = page_from(9, &m, &text("m1", "hi"), ts);
3573+ let _ = app.ingest_poll(&mut m, page);
3574+
3575+ assert_eq!(m.messages[&peer][0].ts, ts);
3576+ }
3577+
3578+ #[test]
3579+ fn message_history_is_capped_at_the_cutoff() {
3580+ let app = Skrepka;
3581+ let mut m = with_identity();
3582+ let peer = peer_hex(9);
3583+
3584+ let convo = m.messages.entry(peer.clone()).or_default();
3585+ for i in 0..MAX_MESSAGES_PER_PEER {
3586+ convo.push(StoredMessage {
3587+ id: format!("old-{i}"),
3588+ body: "x".into(),
3589+ ts: i as i64,
3590+ outgoing: false,
3591+ delivered: false,
3592+ });
3593+ }
3594+
3595+ let page = page_from(9, &m, &text("new", "newest"), now_ms());
3596+ let _ = app.ingest_poll(&mut m, page);
3597+
3598+ let convo = &m.messages[&peer];
3599+ assert_eq!(convo.len(), MAX_MESSAGES_PER_PEER, "the cap holds");
3600+ assert_eq!(convo.last().unwrap().id, "new", "the newest is kept");
3601+ assert_eq!(convo.first().unwrap().id, "old-1", "the oldest is aged out");
3602+ }
3603+
3604+ /// An off-curve key can't be encrypted to, so the send is dropped — but the
3605+ /// user has to be told, rather than watching the message vanish.
3606+ #[test]
3607+ fn a_send_to_an_unusable_key_reports_an_error() {
3608+ let app = Skrepka;
3609+ let mut m = with_identity();
3610+ m.token = Some("t".into());
3611+ let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
3612+ m.outbox
3613+ .push_back(OutboxItem::new(bad, Arc::new("{}".into())));
3614+
3615+ let _ = app.update(Event::StartFlush, &mut m);
3616+
3617+ assert!(m.outbox.is_empty(), "the unsendable item does not wedge the outbox");
3618+ assert!(!m.flushing);
3619+ let err = m.error.expect("the failure is surfaced");
3620+ assert!(err.contains("invalid key"), "got: {err}");
3621+ }
3622+
3623+ #[test]
3624+ fn add_contact_rejects_a_key_that_is_not_a_curve_point() {
3625+ let app = Skrepka;
3626+ let mut m = with_identity();
3627+ let bad = hex::encode([0xabu8; 32]);
3628+
3629+ let _ = app.update(
3630+ Event::AddContact {
3631+ input: bad.clone(),
3632+ nickname: "Mallory".into(),
3633+ },
3634+ &mut m,
3635+ );
3636+
3637+ assert!(!m.contacts.contains_key(&bad), "an unsendable contact is not stored");
3638+ assert!(m.error.is_some());
3639+ }
3640+
3641+ /// A dropped kv write silently loses data; it must not be swallowed.
3642+ #[test]
3643+ fn a_failed_save_surfaces_an_error() {
3644+ let app = Skrepka;
3645+ let mut m = with_identity();
3646+
3647+ let _ = app.update(
3648+ Event::Saved(Err(crux_kv::error::KeyValueError::Io {
3649+ message: "no space left on device".into(),
3650+ })),
3651+ &mut m,
3652+ );
3653+ assert!(m.error.is_some(), "a failed write is reported");
3654+
3655+ // A successful write stays quiet.
3656+ m.error = None;
3657+ let _ = app.update(Event::Saved(Ok(None)), &mut m);
3658+ assert!(m.error.is_none());
3659+ }
3660+
3661+ #[test]
3662+ fn server_host_strips_scheme_port_and_case() {
3663+ assert_eq!(server_host("https://Relay.Example.com:8443/x"), "relay.example.com");
3664+ assert_eq!(server_host("http://localhost:8080"), "localhost");
3665+ assert_eq!(server_host("https://relay.example.com./x"), "relay.example.com");
3666+ // Its only caller passes `settings.server_url`, which is always an
3667+ // absolute URL (`normalize_server_url` guarantees it). Anything else is a
3668+ // bug, and an empty host produces a signature the relay rejects — which is
3669+ // the right failure: loud, not a silently-wrong host binding.
3670+ assert_eq!(server_host("relay.example.com"), "");
3671+ }
3672+
3673+ /// The auth signature is bound to the host (PROTOCOL.md §6), so `server_host`
3674+ /// has to agree with what the relay computes. Splitting on `:` by hand cut an
3675+ /// IPv6 literal at its first colon and produced `[`.
3676+ #[test]
3677+ fn server_host_handles_an_ipv6_literal() {
3678+ assert_eq!(server_host("http://[::1]:8080"), "[::1]");
3679+ assert_eq!(
3680+ server_host("https://[2001:DB8::1]/poll"),
3681+ "[2001:db8::1]"
3682+ );
3683+ }
3684+
3685+ #[test]
3686+ fn backoff_is_capped() {
3687+ assert_eq!(backoff_ms(0), 3000);
3688+ assert_eq!(backoff_ms(1), 6000);
3689+ assert_eq!(backoff_ms(3), 24000);
3690+ assert_eq!(backoff_ms(10), 30000);
3691+ }
3692+
3693+ #[test]
3694+ fn add_contact_accepts_hex_and_rejects_self() {
3695+ let app = Skrepka;
3696+ let mut m = with_identity();
3697+ let peer = peer_hex(9);
3698+ let _ = app.update(
3699+ Event::AddContact {
3700+ input: peer.clone(),
3701+ nickname: "Bob".into(),
3702+ },
3703+ &mut m,
3704+ );
3705+ assert!(m.contacts.contains_key(&peer));
3706+ assert_eq!(m.contacts[&peer].nickname, "Bob");
3707+
3708+ // adding own key is rejected with an error
3709+ let mine = m.my_pubkey.clone();
3710+ let _ = app.update(
3711+ Event::AddContact {
3712+ input: mine,
3713+ nickname: String::new(),
3714+ },
3715+ &mut m,
3716+ );
3717+ assert!(m.error.is_some());
3718+ }
3719+
3720+ /// A stale error from a previous failed add is cleared on the next attempt,
3721+ /// so the AddContact form does not show an old error after the user fixes
3722+ /// the input and tries again.
3723+ #[test]
3724+ fn add_contact_clears_a_stale_error_on_success() {
3725+ let app = Skrepka;
3726+ let mut m = with_identity();
3727+ m.error = Some("invalid public key".into());
3728+
3729+ let peer = peer_hex(9);
3730+ let _ = app.update(
3731+ Event::AddContact {
3732+ input: peer.clone(),
3733+ nickname: "Bob".into(),
3734+ },
3735+ &mut m,
3736+ );
3737+
3738+ assert!(m.error.is_none(), "a successful add clears the stale error");
3739+ assert!(m.contacts.contains_key(&peer));
3740+ }
3741+
3742+ #[test]
3743+ fn send_text_enqueues_message_profile_and_outbox() {
3744+ let app = Skrepka;
3745+ let mut m = with_identity();
3746+ let peer = peer_hex(9);
3747+ m.active_peer = Some(peer.clone());
3748+ m.compose = "hello".into();
3749+
3750+ let _ = app.update(Event::SendText, &mut m);
3751+
3752+ // one outgoing message recorded
3753+ let convo = &m.messages[&peer];
3754+ assert_eq!(convo.len(), 1);
3755+ assert!(convo[0].outgoing);
3756+ assert_eq!(convo[0].body, "hello");
3757+ // compose cleared, contact auto-created
3758+ assert!(m.compose.is_empty());
3759+ assert!(m.contacts.contains_key(&peer));
3760+ // outbox has the text + a first-contact profile
3761+ assert_eq!(m.outbox.len(), 2);
3762+ assert_eq!(m.outbox[0].recipient, peer);
3763+ }
3764+
3765+ #[test]
3766+ fn send_text_without_peer_or_empty_is_noop() {
3767+ let app = Skrepka;
3768+ let mut m = with_identity();
3769+ m.compose = "x".into();
3770+ let _ = app.update(Event::SendText, &mut m); // no active peer
3771+ assert!(m.outbox.is_empty());
3772+
3773+ m.active_peer = Some(peer_hex(9));
3774+ m.compose = " ".into();
3775+ let _ = app.update(Event::SendText, &mut m); // whitespace only
3776+ assert!(m.outbox.is_empty());
3777+ }
3778+
3779+ /// The contact cap must be checked *before* mutating anything. The old
3780+ /// order cleared `compose` and inserted the message into `model.messages`
3781+ /// first, then hit the cap and returned early — losing the user's text and
3782+ /// leaving an invisible orphan conversation that was never persisted.
3783+ #[test]
3784+ fn send_text_to_a_new_peer_at_the_contact_cap_preserves_compose() {
3785+ let app = Skrepka;
3786+ let mut m = with_identity();
3787+ // Fill the contact list to the cap.
3788+ for i in 0..MAX_CONTACTS {
3789+ let k = format!("{i:064x}");
3790+ m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
3791+ }
3792+ let new_peer = peer_hex(9);
3793+ m.active_peer = Some(new_peer.clone());
3794+ m.compose = "hello new friend".into();
3795+
3796+ let _ = app.update(Event::SendText, &mut m);
3797+
3798+ assert_eq!(
3799+ m.compose, "hello new friend",
3800+ "compose must not be cleared on rejection"
3801+ );
3802+ assert!(
3803+ m.messages.get(&new_peer).is_none_or(Vec::is_empty),
3804+ "no orphan conversation must be created"
3805+ );
3806+ assert!(m.outbox.is_empty(), "nothing must be queued");
3807+ assert!(!m.contacts.contains_key(&new_peer), "no contact created");
3808+ assert!(
3809+ m.error.as_ref().is_some_and(|e| e.contains("full")),
3810+ "the user is told why"
3811+ );
3812+ }
3813+
3814+ #[test]
3815+ fn set_blocked_toggles_and_view_reflects_it() {
3816+ let app = Skrepka;
3817+ let mut m = with_identity();
3818+ let peer = peer_hex(9);
3819+ m.contacts
3820+ .insert(peer.clone(), Contact::new(peer.clone(), "Bob".into(), 0));
3821+ let _ = app.update(
3822+ Event::SetBlocked {
3823+ peer: peer.clone(),
3824+ blocked: true,
3825+ },
3826+ &mut m,
3827+ );
3828+ assert!(m.contacts[&peer].blocked);
3829+ let vm = app.view(&m);
3830+ assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
3831+ }
3832+
3833+ /// Unblocking clears a stale "cannot send to a blocked contact" error, so the
3834+ /// chat error display does not persist after the user lifts the block.
3835+ #[test]
3836+ fn unblocking_clears_the_stale_send_error() {
3837+ let app = Skrepka;
3838+ let mut m = with_identity();
3839+ let peer = peer_hex(9);
3840+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
3841+ contact.blocked = true;
3842+ m.contacts.insert(peer.clone(), contact);
3843+ m.error = Some("cannot send to a blocked contact".into());
3844+
3845+ let _ = app.update(
3846+ Event::SetBlocked {
3847+ peer: peer.clone(),
3848+ blocked: false,
3849+ },
3850+ &mut m,
3851+ );
3852+
3853+ assert!(!m.contacts[&peer].blocked);
3854+ assert!(m.error.is_none(), "unblocking clears the stale error");
3855+ }
3856+
3857+ /// Gating ingest stops the acks we have not queued yet — but the ack for the
3858+ /// message that made the user hit "block" is already in the outbox, and an ack
3859+ /// is exactly the "we are online and reading you" signal the block exists to
3860+ /// cut off.
3861+ #[test]
3862+ fn blocking_a_peer_drops_what_is_already_queued_for_them() {
3863+ let app = Skrepka;
3864+ let mut m = with_identity();
3865+ let enemy = peer_hex(9);
3866+ let friend = peer_hex(11);
3867+ m.contacts
3868+ .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
3869+
3870+ // An ack to the peer we are about to block, plus unrelated mail.
3871+ for recipient in [&enemy, &friend] {
3872+ m.outbox.push_back(OutboxItem::new(
3873+ recipient.clone(),
3874+ Arc::new(protocol::serialize_payload(
3875+ &Payload::DeliveryAck {
3876+ ack_ids: vec!["m1".into()],
3877+ },
3878+ 1,
3879+ )),
3880+ ));
3881+ }
3882+
3883+ let _ = app.update(
3884+ Event::SetBlocked {
3885+ peer: enemy.clone(),
3886+ blocked: true,
3887+ },
3888+ &mut m,
3889+ );
3890+
3891+ let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
3892+ assert_eq!(
3893+ recipients,
3894+ vec![friend.as_str()],
3895+ "nothing more goes to a blocked peer — and everyone else is untouched"
3896+ );
3897+ }
3898+
3899+ #[test]
3900+ fn normalize_server_url_requires_an_http_scheme() {
3901+ // The real parser canonicalizes: scheme and host are lowercased.
3902+ assert_eq!(
3903+ normalize_server_url(" https://Relay.Example.com/ "),
3904+ Some("https://relay.example.com".to_string())
3905+ );
3906+ assert_eq!(
3907+ normalize_server_url("HTTP://localhost:8080///"),
3908+ Some("http://localhost:8080".to_string())
3909+ );
3910+ assert_eq!(
3911+ normalize_server_url("http://[::1]:8080"),
3912+ Some("http://[::1]:8080".to_string())
3913+ );
3914+ // The cases that used to panic Http::post or produce a dead URL.
3915+ assert_eq!(normalize_server_url("relay.example.com"), None);
3916+ assert_eq!(normalize_server_url("localhost:8080"), None);
3917+ assert_eq!(normalize_server_url("ftp://relay.example.com"), None);
3918+ assert_eq!(normalize_server_url("http://"), None);
3919+ assert_eq!(normalize_server_url("http://:8080"), None);
3920+ assert_eq!(normalize_server_url("https://relay example.com"), None);
3921+ assert_eq!(normalize_server_url(""), None);
3922+ }
3923+
3924+ /// The hand-rolled validator this replaces was not the parser it guarded:
3925+ /// it waved these through, and `crux_http` unwraps the parse.
3926+ #[test]
3927+ fn normalize_server_url_rejects_what_the_url_parser_rejects() {
3928+ assert_eq!(normalize_server_url("http://[bad"), None);
3929+ assert_eq!(normalize_server_url("http://ho^st"), None);
3930+ assert_eq!(normalize_server_url("http://a\u{7f}b"), None);
3931+ }
3932+
3933+ /// Every request appends a path, so a query or fragment would silently
3934+ /// swallow it: `http://x?y` + "/poll" is `http://x?y/poll` — a request to `/`
3935+ /// with a junk query, not to the poll endpoint.
3936+ #[test]
3937+ fn normalize_server_url_rejects_a_query_or_fragment() {
3938+ assert_eq!(normalize_server_url("http://x?y"), None);
3939+ assert_eq!(normalize_server_url("https://relay.example.com/#frag"), None);
3940+ }
3941+
3942+ #[test]
3943+ fn set_server_url_rejects_schemeless_and_keeps_the_old_url() {
3944+ let app = Skrepka;
3945+ let mut m = with_identity();
3946+ let original = m.settings.server_url.clone();
3947+
3948+ let _ = app.update(Event::SetServerUrl("relay.example.com".into()), &mut m);
3949+ assert_eq!(m.settings.server_url, original, "bad URL must not persist");
3950+ assert!(m.error.is_some());
3951+
3952+ let _ = app.update(
3953+ Event::SetServerUrl(" https://relay.example.com/ ".into()),
3954+ &mut m,
3955+ );
3956+ assert_eq!(m.settings.server_url, "https://relay.example.com");
3957+ assert!(m.error.is_none());
3958+ assert!(m.token.is_none());
3959+ assert_eq!(m.conn, ConnStatus::Offline);
3960+ }
3961+
3962+ /// The regression that stalled the outbox: `flushing` stayed `true` forever
3963+ /// after the first successful send, so nothing was ever sent again.
3964+ #[test]
3965+ fn send_result_ok_clears_flushing() {
3966+ let app = Skrepka;
3967+ let mut m = with_identity();
3968+ m.token = Some("t".into());
3969+ m.outbox
3970+ .push_back(OutboxItem::new(peer_hex(9), Arc::new("{}".into())));
3971+
3972+ let _ = app.update(Event::StartFlush, &mut m);
3973+ assert!(m.flushing);
3974+
3975+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3976+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
3977+
3978+ assert!(!m.flushing, "flushing must be cleared on the success path");
3979+ assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
3980+ }
3981+
3982+ #[test]
3983+ fn flush_next_resumes_after_a_successful_send() {
3984+ let app = Skrepka;
3985+ let mut m = with_identity();
3986+ m.token = Some("t".into());
3987+ // Two different peers, or the batching would send both at once.
3988+ for seed in [9u8, 11] {
3989+ m.outbox
3990+ .push_back(OutboxItem::new(peer_hex(seed), Arc::new("{}".into())));
3991+ }
3992+ // First send goes out and marks the model as flushing.
3993+ let _ = app.update(Event::StartFlush, &mut m);
3994+ assert!(m.flushing);
3995+
3996+ // Its success must both drop the item and let the *next* one go out.
3997+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3998+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
3999+ assert_eq!(m.outbox.len(), 1);
4000+ assert!(m.flushing, "the second item is now in flight");
4001+ }
4002+
4003+ #[test]
4004+ fn poll_is_a_noop_while_a_poll_is_already_in_flight() {
4005+ let app = Skrepka;
4006+ let mut m = with_identity();
4007+ m.token = Some("t".into());
4008+
4009+ let _ = app.update(Event::Poll, &mut m);
4010+ assert!(m.polling, "the first Poll issues a request");
4011+ let gen = m.poll_gen;
4012+
4013+ // A second Poll (e.g. from a re-auth) must not stack another loop.
4014+ let _ = app.update(Event::Poll, &mut m);
4015+ assert!(m.polling);
4016+ assert_eq!(m.poll_gen, gen, "and does not open a new generation");
4017+
4018+ // Both success and failure release the guard so the loop can continue.
4019+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4020+ let _ = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
4021+ assert!(!m.polling);
4022+
4023+ let _ = app.update(Event::Poll, &mut m);
4024+ assert!(m.polling);
4025+ let _ = app.update(
4026+ Event::PollResult(m.poll_gen, Err(crux_http::HttpError::Timeout)),
4027+ &mut m,
4028+ );
4029+ assert!(!m.polling);
4030+ }
4031+
4032+ /// The cursor is the *relay's* sequence number, and the next poll acks
4033+ /// everything up to it. Carrying relay A's cursor over to relay B tells B to
4034+ /// delete every message waiting for us before handing any of them over.
4035+ #[test]
4036+ fn switching_relays_resets_the_cursor() {
4037+ let app = Skrepka;
4038+ let mut m = with_identity();
4039+ m.cursor = now_ms();
4040+ m.token = Some("t".into());
4041+ m.conn = ConnStatus::Online;
4042+
4043+ let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
4044+
4045+ assert_eq!(m.cursor, 0, "the new relay is polled from the beginning");
4046+ assert!(m.token.is_none(), "the old relay's token is worthless");
4047+ assert_eq!(m.conn, ConnStatus::Offline);
4048+ }
4049+
4050+ /// Resetting the cursor is not enough on its own: the poll already in flight
4051+ /// is addressed to the *old* relay and its page carries the old relay's
4052+ /// sequence number. Letting it land writes that number straight back over the
4053+ /// reset, and the next poll hands it to the new relay — which reads it as an
4054+ /// ack and drops every message queued for us before serving any of them.
4055+ #[test]
4056+ fn switching_relays_retires_the_poll_still_in_flight_at_the_old_one() {
4057+ let app = Skrepka;
4058+ let mut m = with_identity();
4059+ m.token = Some("t".into());
4060+ m.conn = ConnStatus::Online;
4061+
4062+ let _ = app.update(Event::Poll, &mut m);
4063+ let old_relay_poll = m.poll_gen;
4064+ assert!(m.polling);
4065+
4066+ let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
4067+ assert!(!m.polling, "the old relay's poll no longer holds the guard");
4068+
4069+ // It resolves after the switch, carrying the old relay's cursor.
4070+ let body = serde_json::to_vec(&serde_json::json!({
4071+ "events": [],
4072+ "cursor": 1_700_000_000_000i64,
4073+ }))
4074+ .unwrap();
4075+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4076+ let _ = app.update(Event::PollResult(old_relay_poll, Ok(resp)), &mut m);
4077+
4078+ assert_eq!(
4079+ m.cursor, 0,
4080+ "the new relay must not be handed a cursor from a sequence it never issued"
4081+ );
4082+ }
4083+
4084+ /// A URL that the parser rejects reaches `Http::post`, which unwraps it. On
4085+ /// disk it is a panic on every launch — a boot loop with no way out.
4086+ #[test]
4087+ fn a_corrupt_persisted_server_url_falls_back_to_the_default() {
4088+ let app = Skrepka;
4089+ let mut m = with_identity();
4090+ m.loads_pending = STARTUP_LOADS;
4091+
4092+ let junk = serde_json::to_vec(&serde_json::json!({"server_url": "http://[bad"})).unwrap();
4093+ let _ = app.update(Event::LoadedSettings(Ok(Some(junk))), &mut m);
4094+
4095+ assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
4096+ assert!(
4097+ normalize_server_url(&m.settings.server_url).is_some(),
4098+ "whatever we end up with must survive the parser"
4099+ );
4100+ }
4101+
4102+ /// A settings blob written before a field existed must still load — otherwise
4103+ /// `parse_kv` returns None, the default is used, and the next write destroys
4104+ /// what was there.
4105+ #[test]
4106+ fn an_old_settings_blob_still_loads() {
4107+ let app = Skrepka;
4108+ let mut m = with_identity();
4109+ m.loads_pending = STARTUP_LOADS;
4110+
4111+ let empty = serde_json::to_vec(&serde_json::json!({})).unwrap();
4112+ let _ = app.update(Event::LoadedSettings(Ok(Some(empty))), &mut m);
4113+ assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
4114+
4115+ // ...and a Contact missing every optional field still becomes a contact.
4116+ let peer = peer_hex(9);
4117+ let old = serde_json::to_vec(&serde_json::json!([{"pubkey": peer}])).unwrap();
4118+ let _ = app.update(Event::LoadedContacts(Ok(Some(old))), &mut m);
4119+ assert!(m.contacts.contains_key(&peer));
4120+ }
4121+
4122+ /// Connecting before the startup loads land lets a poll be ingested against a
4123+ /// half-loaded model — and the loads still in flight then overwrite it.
4124+ #[test]
4125+ fn connect_waits_for_every_startup_load() {
4126+ let app = Skrepka;
4127+ let mut m = with_identity();
4128+ m.loads_pending = STARTUP_LOADS;
4129+
4130+ let connects = |cmd: &mut Command<Effect, Event>| {
4131+ cmd.events()
4132+ .filter(|e| matches!(e, Event::Connect))
4133+ .count()
4134+ };
4135+
4136+ for (i, ev) in [
4137+ Event::LoadedSettings(Ok(None)),
4138+ Event::LoadedProfile(Ok(None)),
4139+ Event::LoadedContacts(Ok(None)),
4140+ Event::LoadedCursor(Ok(None)),
4141+ Event::LoadedSeenIds(Ok(None)),
4142+ ]
4143+ .into_iter()
4144+ .enumerate()
4145+ {
4146+ let mut cmd = app.update(ev, &mut m);
4147+ assert_eq!(
4148+ m.loads_pending,
4149+ STARTUP_LOADS - 1 - i as u8,
4150+ "each load ticks the counter down"
4151+ );
4152+ assert_eq!(connects(&mut cmd), 0, "and none of them connects");
4153+ }
4154+
4155+ // The sixth — and only the sixth — emits Connect.
4156+ let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
4157+ assert_eq!(m.loads_pending, 0);
4158+ assert_eq!(connects(&mut cmd), 1, "the last load connects, exactly once");
4159+
4160+ // A late duplicate (a re-delivered load) must not connect again.
4161+ let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
4162+ assert_eq!(connects(&mut cmd), 0);
4163+ }
4164+
4165+ /// The sharpest edge of a failed read, and the one that costs the user real
4166+ /// messages: `ingest_poll` persists the outbox on *every* poll page, including
4167+ /// an empty one. So a single unreadable outbox — a background wake with the
4168+ /// device locked — followed by one idle poll used to write `[]` over a queue of
4169+ /// unsent mail. The client destroys the user's messages by doing nothing at all.
4170+ ///
4171+ /// A read failure is not an empty key. Nothing may be written until a launch
4172+ /// reads the key successfully.
4173+ #[test]
4174+ fn a_failed_read_never_writes_over_what_it_could_not_read() {
4175+ let app = Skrepka;
4176+ let mut m = with_identity();
4177+ m.loads_pending = STARTUP_LOADS;
4178+
4179+ let connects = |cmd: &mut Command<Effect, Event>| {
4180+ cmd.events()
4181+ .filter(|e| matches!(e, Event::Connect))
4182+ .count()
4183+ };
4184+ // Every key a command actually writes. The model's own view of what it saved
4185+ // cannot see these — the write lives entirely in the effect.
4186+ let kv_writes = |cmd: &mut Command<Effect, Event>| {
4187+ cmd.effects()
4188+ .filter_map(|e| match e {
4189+ Effect::KeyValue(req) => match &req.operation {
4190+ crux_kv::KeyValueOperation::Set { key, .. } => Some(key.clone()),
4191+ _ => None,
4192+ },
4193+ _ => None,
4194+ })
4195+ .collect::<Vec<_>>()
4196+ };
4197+
4198+ let _ = app.update(Event::LoadedSettings(Ok(None)), &mut m);
4199+ let _ = app.update(Event::LoadedProfile(Ok(None)), &mut m);
4200+ let _ = app.update(Event::LoadedContacts(Ok(None)), &mut m);
4201+ let _ = app.update(Event::LoadedCursor(Ok(None)), &mut m);
4202+ // The outbox is the one that could not be read — a background wake with the
4203+ // device locked. The model now believes it is empty; the disk says otherwise.
4204+ let mut cmd = app.update(
4205+ Event::LoadedOutbox(Err(crux_kv::error::KeyValueError::Io {
4206+ message: "device is locked".into(),
4207+ })),
4208+ &mut m,
4209+ );
4210+
4211+ assert!(m.kv_load_failed, "the model knows it has a hole in it");
4212+ assert!(m.error.is_some(), "and says so");
4213+ assert_eq!(
4214+ connects(&mut cmd),
4215+ 0,
4216+ "the last load does not connect: a poll would ack mail we cannot store"
4217+ );
4218+
4219+ // Every write path is closed, whatever tries to open it.
4220+ let mut cmd = app.update(
4221+ Event::AddContact {
4222+ input: peer_hex(9),
4223+ nickname: "n".into(),
4224+ },
4225+ &mut m,
4226+ );
4227+ assert!(
4228+ kv_writes(&mut cmd).is_empty(),
4229+ "contacts are not written over an unread model"
4230+ );
4231+
4232+ // And the regression itself. `ingest_poll` persists the outbox on every page,
4233+ // so one idle poll used to be enough to write `[]` over the queue on disk.
4234+ m.token = Some("t".into());
4235+ let resp = crux_http::testing::ResponseBuilder::ok()
4236+ .body(br#"{"events":[],"cursor":42}"#.to_vec())
4237+ .build();
4238+ let mut cmd = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
4239+ assert!(
4240+ kv_writes(&mut cmd).is_empty(),
4241+ "an empty poll writes nothing — not the outbox, not the cursor"
4242+ );
4243+
4244+ // The recovery is a relaunch, which re-runs the loads and clears the latch.
4245+ let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
4246+ let _ = app.update(Event::IdentityLoaded(sk), &mut m);
4247+ assert!(
4248+ !m.kv_load_failed,
4249+ "a fresh round of loads gets to answer for itself"
4250+ );
4251+ }
4252+
4253+ /// `Authenticate` is reachable from startup, a 401 on poll, a 401 on send and
4254+ /// a reconnect timer. Without a guard a network blip fires all of them, and
4255+ /// every token that comes back starts its own poll loop.
4256+ #[test]
4257+ fn authentication_does_not_stack() {
4258+ let app = Skrepka;
4259+ let mut m = with_identity();
4260+
4261+ let _ = app.update(Event::Authenticate, &mut m);
4262+ assert!(m.authenticating, "the first one goes out");
4263+ assert_eq!(m.conn, ConnStatus::Connecting);
4264+
4265+ // A second Authenticate while the first is in flight is a no-op.
4266+ m.conn = ConnStatus::Offline;
4267+ let _ = app.update(Event::Authenticate, &mut m);
4268+ assert_eq!(m.conn, ConnStatus::Offline, "no second challenge was issued");
4269+
4270+ // Any terminal outcome releases the guard.
4271+ let _ = app.update(
4272+ Event::ChallengeResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
4273+ &mut m,
4274+ );
4275+ assert!(!m.authenticating);
4276+
4277+ let _ = app.update(Event::Authenticate, &mut m);
4278+ assert!(m.authenticating);
4279+ let _ = app.update(
4280+ Event::VerifyResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
4281+ &mut m,
4282+ );
4283+ assert!(!m.authenticating);
4284+ }
4285+
4286+ /// The shell fires `Connect` on *every* return to the foreground
4287+ /// (`SkrepkaApp.swift`), so a challenge issued before the app was backgrounded
4288+ /// is routinely still in flight when the next attempt starts. It must not come
4289+ /// back and install a token, or clobber `conn`, on top of the attempt that
4290+ /// replaced it.
4291+ #[test]
4292+ fn an_auth_round_trip_superseded_by_connect_cannot_land() {
4293+ let app = Skrepka;
4294+ let mut m = with_identity();
4295+
4296+ let _ = app.update(Event::Authenticate, &mut m);
4297+ let superseded = m.auth_gen;
4298+ assert!(m.authenticating);
4299+
4300+ // Foregrounding restarts the flow.
4301+ dispatch(&app, Event::Connect, &mut m);
4302+ let live = m.auth_gen;
4303+ assert_ne!(live, superseded, "the in-flight attempt was retired");
4304+ assert!(m.authenticating, "and a fresh one went out");
4305+
4306+ // The old challenge finally answers. It is not ours any more.
4307+ let body = serde_json::to_vec(&serde_json::json!({"challenge": "deadbeef"})).unwrap();
4308+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4309+ let _ = app.update(Event::ChallengeResult(superseded, Ok(resp)), &mut m);
4310+ assert!(
4311+ m.authenticating,
4312+ "the live attempt still holds the guard — a stale leg must not release it"
4313+ );
4314+
4315+ // Nor may a stale *verify* install a token over the live attempt.
4316+ let body = serde_json::to_vec(&serde_json::json!({"token": "stale"})).unwrap();
4317+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4318+ let _ = app.update(Event::VerifyResult(superseded, Ok(resp)), &mut m);
4319+ assert!(m.token.is_none(), "no token from a superseded attempt");
4320+ assert_ne!(m.conn, ConnStatus::Online);
4321+
4322+ // The live attempt still completes normally.
4323+ let body = serde_json::to_vec(&serde_json::json!({"token": "real"})).unwrap();
4324+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4325+ let _ = app.update(Event::VerifyResult(live, Ok(resp)), &mut m);
4326+ assert_eq!(m.token.as_deref(), Some("real"));
4327+ assert_eq!(m.conn, ConnStatus::Online);
4328+ }
4329+
4330+ /// Blocking is meant to cut a peer off in *both* directions.
4331+ #[test]
4332+ fn a_blocked_peer_cannot_update_their_profile_or_ack_our_messages() {
4333+ let app = Skrepka;
4334+ let mut m = with_identity();
4335+ let peer = peer_hex(9);
4336+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
4337+ contact.blocked = true;
4338+ contact.display_name = "Bob".into();
4339+ m.contacts.insert(peer.clone(), contact);
4340+ m.messages.insert(
4341+ peer.clone(),
4342+ vec![StoredMessage {
4343+ id: "mine".into(),
4344+ body: "hi".into(),
4345+ ts: 1,
4346+ outgoing: true,
4347+ delivered: false,
4348+ }],
4349+ );
4350+
4351+ let profile = Payload::Profile {
4352+ display_name: Some("Mallory".into()),
4353+ bio: Some("spam".into()),
4354+ photo: Some("aGk=".into()),
4355+ };
4356+ let page = page_from(9, &m, &profile, now_ms());
4357+ let _ = app.ingest_poll(&mut m, page);
4358+ assert_eq!(
4359+ m.contacts[&peer].display_name, "Bob",
4360+ "a blocked peer cannot rewrite their entry in our contact list"
4361+ );
4362+
4363+ let ack = Payload::DeliveryAck {
4364+ ack_ids: vec!["mine".into()],
4365+ };
4366+ let page = page_from(9, &m, &ack, now_ms());
4367+ let _ = app.ingest_poll(&mut m, page);
4368+ assert!(
4369+ !m.messages[&peer][0].delivered,
4370+ "nor tell us they read what we sent before the block"
4371+ );
4372+ }
4373+
4374+ /// Blocking someone and then updating our profile must not hand them our new
4375+ /// display name, avatar, and a liveness signal.
4376+ #[test]
4377+ fn save_profile_does_not_broadcast_to_blocked_contacts() {
4378+ let app = Skrepka;
4379+ let mut m = with_identity();
4380+ let friend = peer_hex(9);
4381+ let enemy = peer_hex(11);
4382+ m.contacts
4383+ .insert(friend.clone(), Contact::new(friend.clone(), "Bob".into(), 0));
4384+ let mut blocked = Contact::new(enemy.clone(), "Mallory".into(), 0);
4385+ blocked.blocked = true;
4386+ m.contacts.insert(enemy.clone(), blocked);
4387+
4388+ let _ = app.update(
4389+ Event::SaveProfile {
4390+ display_name: "Alice".into(),
4391+ bio: String::new(),
4392+ photo: None,
4393+ },
4394+ &mut m,
4395+ );
4396+
4397+ let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
4398+ assert_eq!(recipients, vec![friend.as_str()]);
4399+ }
4400+
4401+ /// Any stranger can create a contact just by writing to us, so the list needs
4402+ /// a ceiling — and past it, the message goes too: a stored conversation with
4403+ /// no contact entry is one nothing can open and nothing ever trims.
4404+ #[test]
4405+ fn contacts_from_strangers_are_capped() {
4406+ let app = Skrepka;
4407+ let mut m = with_identity();
4408+ for i in 0..MAX_CONTACTS {
4409+ let k = format!("{i:064x}");
4410+ m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
4411+ }
4412+ assert_eq!(m.contacts.len(), MAX_CONTACTS);
4413+
4414+ let peer = peer_hex(9);
4415+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
4416+ let _ = app.ingest_poll(&mut m, page);
4417+
4418+ assert_eq!(m.contacts.len(), MAX_CONTACTS, "the cap holds");
4419+ assert!(!m.contacts.contains_key(&peer));
4420+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty));
4421+ assert!(m.outbox.is_empty(), "and nothing is acked back");
4422+ }
4423+
4424+ /// A bare profile is an unsolicited push with no message attached. Honouring
4425+ /// it would let anyone holding our pubkey install an entry — with an avatar —
4426+ /// in our contact list without ever saying a word.
4427+ #[test]
4428+ fn a_profile_from_a_stranger_does_not_create_a_contact() {
4429+ let app = Skrepka;
4430+ let mut m = with_identity();
4431+ let peer = peer_hex(9);
4432+
4433+ let profile = Payload::Profile {
4434+ display_name: Some("Mallory".into()),
4435+ bio: Some(String::new()),
4436+ photo: Some("aGk=".into()),
4437+ };
4438+ let page = page_from(9, &m, &profile, now_ms());
4439+ let _ = app.ingest_poll(&mut m, page);
4440+ assert!(m.contacts.is_empty(), "no contact from a bare profile");
4441+
4442+ // But a profile from someone we *do* know still applies.
4443+ m.contacts
4444+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4445+ let page = page_from(9, &m, &profile, now_ms());
4446+ let _ = app.ingest_poll(&mut m, page);
4447+ assert_eq!(m.contacts[&peer].display_name, "Mallory");
4448+ }
4449+
4450+ /// A relay cannot forge mail from us — the sender identity is signed inside the
4451+ /// AEAD — but it can *echo back* a blob we sent through it, and one addressed to
4452+ /// ourselves decrypts perfectly well. Nothing downstream would notice: it would
4453+ /// file our own words as incoming, put our own key in our contact list (which
4454+ /// `AddContact` explicitly refuses), and queue a delivery ack addressed to us —
4455+ /// which flushes, comes back, and is acked again.
4456+ #[test]
4457+ fn a_message_from_ourselves_is_dropped() {
4458+ let app = Skrepka;
4459+ let mut m = with_identity();
4460+ let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4461+
4462+ let json = protocol::serialize_payload(&text("m1", "echo"), now_ms());
4463+ let blob = crate::crypto::encrypt(
4464+ &mut rand_core::OsRng,
4465+ &me,
4466+ &me.public_key(),
4467+ json.as_bytes(),
4468+ )
4469+ .unwrap();
4470+ let page = PollResp {
4471+ events: vec![PollEvent {
4472+ encrypted_blob: hex::encode(blob),
4473+ }],
4474+ cursor: 1,
4475+ };
4476+
4477+ let _ = app.ingest_poll(&mut m, page);
4478+
4479+ assert!(
4480+ !m.contacts.contains_key(&m.my_pubkey),
4481+ "we are not our own contact"
4482+ );
4483+ assert!(m.messages.is_empty(), "and our own words are not incoming mail");
4484+ assert!(m.outbox.is_empty(), "and we do not ack ourselves");
4485+ }
4486+
4487+ /// `polling` gates the whole long-poll loop and is cleared only by a
4488+ /// `PollResult`. If the shell ever drops the HTTP effect, the client goes
4489+ /// silent forever with no visible error.
4490+ #[test]
4491+ fn the_watchdog_unwedges_a_poll_that_never_resolved() {
4492+ let app = Skrepka;
4493+ let mut m = with_identity();
4494+ m.token = Some("t".into());
4495+
4496+ let _ = app.update(Event::Poll, &mut m);
4497+ assert!(m.polling);
4498+ let wedged = m.poll_gen;
4499+
4500+ // The watchdog armed for *this* poll releases the guard and restarts the
4501+ // loop — which issues a fresh poll under a new generation.
4502+ dispatch(&app, Event::PollWatchdog(wedged), &mut m);
4503+ assert!(m.polling, "the loop restarted");
4504+ assert_ne!(m.poll_gen, wedged, "under a new generation");
4505+ }
4506+
4507+ /// A watchdog is armed per poll and nothing cancels the earlier ones, so a
4508+ /// timer for a poll that has already completed keeps firing. It must not
4509+ /// touch the healthy poll that replaced it.
4510+ #[test]
4511+ fn a_stale_watchdog_leaves_the_current_poll_alone() {
4512+ let app = Skrepka;
4513+ let mut m = with_identity();
4514+ m.token = Some("t".into());
4515+
4516+ let _ = app.update(Event::Poll, &mut m);
4517+ let first = m.poll_gen;
4518+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4519+ let _ = app.update(Event::PollResult(first, Ok(resp)), &mut m);
4520+ let _ = app.update(Event::Poll, &mut m);
4521+ let current = m.poll_gen;
4522+ assert!(m.polling);
4523+
4524+ let _ = app.update(Event::PollWatchdog(first), &mut m);
4525+ assert!(m.polling, "the live poll is untouched");
4526+ assert_eq!(m.poll_gen, current, "and not superseded");
4527+ }
4528+
4529+ /// The regression the generation exists for.
4530+ ///
4531+ /// An HTTP effect cannot be cancelled, only abandoned — and it stays pending
4532+ /// across an iOS suspension while the wall clock runs on, so on resume the
4533+ /// watchdog gives up on a poll the shell is still about to resolve. If that
4534+ /// late `PollResult` were honoured it would clear `polling` — a guard that by
4535+ /// then belongs to the *replacement* poll — and chain a re-poll of its own.
4536+ /// Two loops, each re-polling itself forever, doubling on every recurrence.
4537+ #[test]
4538+ fn a_poll_abandoned_by_the_watchdog_cannot_fork_the_loop_when_it_lands() {
4539+ let app = Skrepka;
4540+ let mut m = with_identity();
4541+ m.token = Some("t".into());
4542+
4543+ let _ = app.update(Event::Poll, &mut m);
4544+ let abandoned = m.poll_gen;
4545+
4546+ // The watchdog gives up on it; the loop restarts under a new generation.
4547+ dispatch(&app, Event::PollWatchdog(abandoned), &mut m);
4548+ let live = m.poll_gen;
4549+ assert!(m.polling && live != abandoned);
4550+
4551+ // Now the abandoned request finally resolves. It must be ignored outright.
4552+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4553+ let mut cmd = app.update(Event::PollResult(abandoned, Ok(resp)), &mut m);
4554+
4555+ assert!(m.polling, "the live poll still holds the guard");
4556+ assert_eq!(m.poll_gen, live);
4557+ assert_eq!(
4558+ cmd.events().count(),
4559+ 0,
4560+ "and the corpse chains nothing — no second loop"
4561+ );
4562+ }
4563+
4564+ /// Dropping an abandoned page loses nothing: its cursor was never written, so
4565+ /// the relay still holds every message in it and re-delivers them.
4566+ #[test]
4567+ fn an_abandoned_poll_page_is_not_ingested() {
4568+ let app = Skrepka;
4569+ let mut m = with_identity();
4570+ m.token = Some("t".into());
4571+ let peer = peer_hex(9);
4572+
4573+ let _ = app.update(Event::Poll, &mut m);
4574+ let abandoned = m.poll_gen;
4575+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
4576+ let body = serde_json::to_vec(&serde_json::json!({
4577+ "events": [{"encryptedBlob": page.events[0].encrypted_blob}],
4578+ "cursor": 12345,
4579+ }))
4580+ .unwrap();
4581+
4582+ dispatch(&app, Event::PollWatchdog(abandoned), &mut m);
4583+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4584+ let _ = app.update(Event::PollResult(abandoned, Ok(resp)), &mut m);
4585+
4586+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty));
4587+ assert_eq!(m.cursor, 0, "and the stale cursor is not adopted");
4588+ }
4589+
4590+ /// `view()` runs per keystroke (`ComposeChanged` renders), so cloning every
4591+ /// contact's base64 avatar each time meant megabytes of allocation to type one
4592+ /// message. Only the chat page has a composer, and while it is up the only
4593+ /// avatar on screen is the peer we are talking to — so that is the only page
4594+ /// that withholds anything.
4595+ ///
4596+ /// The conversations list draws an avatar per row (`ConversationsView.row`).
4597+ /// Blanking photos there buys nothing — no keystroke renders that page — and
4598+ /// costs every contact their picture.
4599+ #[test]
4600+ fn photos_are_withheld_on_the_chat_page_and_nowhere_else() {
4601+ let app = Skrepka;
4602+ let mut m = with_identity();
4603+ let a = peer_hex(9);
4604+ let b = peer_hex(11);
4605+ for peer in [&a, &b] {
4606+ let mut c = Contact::new(peer.clone(), String::new(), 0);
4607+ c.photo = Some("aGVsbG8=".into());
4608+ m.contacts.insert(peer.clone(), c);
4609+ }
4610+ let photo_of = |vm: &ViewModel, k: &str| {
4611+ vm.contacts
4612+ .iter()
4613+ .find(|c| c.pubkey == k)
4614+ .map(|c| c.photo.clone())
4615+ .unwrap()
4616+ };
4617+
4618+ // Conversations list: every row keeps its avatar.
4619+ let vm = app.view(&m);
4620+ assert_eq!(photo_of(&vm, &a), "aGVsbG8=");
4621+ assert_eq!(photo_of(&vm, &b), "aGVsbG8=");
4622+
4623+ // Open a chat: exactly that peer's photo, and no one else's — this is the
4624+ // page that re-renders on every keystroke.
4625+ m.page = Page::Chat;
4626+ m.active_peer = Some(a.clone());
4627+ let vm = app.view(&m);
4628+ assert_eq!(photo_of(&vm, &a), "aGVsbG8=");
4629+ assert!(photo_of(&vm, &b).is_empty());
4630+ }
4631+
4632+ /// `parse_payload` drops a `delivery.ack` with more than `MAX_ACK_IDS` ids, and
4633+ /// a peer running this code applies that cap to us. One ack payload per sender
4634+ /// therefore stops working the moment a peer has more than the cap waiting for
4635+ /// us — a long offline stretch — and those messages stay un-delivered on their
4636+ /// side forever, because a page is only ever acked once.
4637+ #[test]
4638+ fn acks_are_batched_so_the_peer_can_actually_parse_them() {
4639+ let app = Skrepka;
4640+ let mut m = with_identity();
4641+ let peer = peer_hex(9);
4642+ m.contacts
4643+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4644+
4645+ // One page holding more messages from one sender than fits in a single ack.
4646+ let count = protocol::MAX_ACK_IDS + 5;
4647+ let sender = Identity::from_seed(&[9u8; 32]).unwrap();
4648+ let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4649+ let events = (0..count)
4650+ .map(|i| {
4651+ let json = protocol::serialize_payload(&text(&format!("m{i}"), "hi"), now_ms());
4652+ let blob = crate::crypto::encrypt(
4653+ &mut rand_core::OsRng,
4654+ &sender,
4655+ &me.public_key(),
4656+ json.as_bytes(),
4657+ )
4658+ .unwrap();
4659+ PollEvent {
4660+ encrypted_blob: hex::encode(blob),
4661+ }
4662+ })
4663+ .collect();
4664+ let _ = app.ingest_poll(&mut m, PollResp { events, cursor: 1 });
4665+
4666+ // Local history is capped as always — but every message we *received* is
4667+ // acked, whether or not we chose to keep it.
4668+ assert_eq!(m.messages[&peer].len(), MAX_MESSAGES_PER_PEER);
4669+ assert_eq!(m.outbox.len(), 2, "the acks are split into parseable batches");
4670+
4671+ // And every batch really does survive the recipient's parser.
4672+ let mut acked = 0;
4673+ for item in &m.outbox {
4674+ assert_eq!(item.recipient, peer);
4675+ let parsed = protocol::parse_payload(item.envelope_json.as_bytes())
4676+ .expect("a peer must be able to parse the ack we send it");
4677+ match parsed.payload {
4678+ Payload::DeliveryAck { ack_ids } => {
4679+ assert!(ack_ids.len() <= protocol::MAX_ACK_IDS);
4680+ acked += ack_ids.len();
4681+ }
4682+ _ => panic!("expected a delivery.ack"),
4683+ }
4684+ }
4685+ assert_eq!(acked, count, "and between them they ack the whole page");
4686+ }
4687+
4688+ #[test]
4689+ fn view_exposes_identity_and_ob() {
4690+ let app = Skrepka;
4691+ let m = with_identity();
4692+ let vm = app.view(&m);
4693+ assert!(vm.has_identity);
4694+ assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
4695+ assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
4696+ }
4697+
4698+ /// A replayed delivery.ack with an older `ts` must be dropped, and
4699+ /// `last_ack_ts` must not regress — otherwise a relay re-delivering an old
4700+ /// page could flip messages back to "not delivered" or keep a stale ack live.
4701+ #[test]
4702+ fn a_replayed_delivery_ack_is_ignored() {
4703+ let app = Skrepka;
4704+ let mut m = with_identity();
4705+ let peer = peer_hex(9);
4706+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
4707+ contact.last_ack_ts = 100;
4708+ m.contacts.insert(peer.clone(), contact);
4709+ m.messages.insert(
4710+ peer.clone(),
4711+ vec![StoredMessage {
4712+ id: "m1".into(),
4713+ body: "hi".into(),
4714+ ts: 50,
4715+ outgoing: true,
4716+ delivered: false,
4717+ }],
4718+ );
4719+
4720+ // An ack with ts = 50 (< 100) is stale → ignored.
4721+ let ack = Payload::DeliveryAck {
4722+ ack_ids: vec!["m1".into()],
4723+ };
4724+ let page = page_from(9, &m, &ack, 50);
4725+ let _ = app.ingest_poll(&mut m, page);
4726+ assert!(
4727+ !m.messages[&peer][0].delivered,
4728+ "a stale ack must not mark the message delivered"
4729+ );
4730+ assert_eq!(
4731+ m.contacts[&peer].last_ack_ts, 100,
4732+ "last_ack_ts must not regress"
4733+ );
4734+
4735+ // An ack with ts = 200 (> 100) passes → message is marked delivered.
4736+ let ack = Payload::DeliveryAck {
4737+ ack_ids: vec!["m1".into()],
4738+ };
4739+ let page = page_from(9, &m, &ack, 200);
4740+ let _ = app.ingest_poll(&mut m, page);
4741+ assert!(
4742+ m.messages[&peer][0].delivered,
4743+ "a fresh ack must mark the message delivered"
4744+ );
4745+ assert_eq!(
4746+ m.contacts[&peer].last_ack_ts, 200,
4747+ "last_ack_ts advances to the fresh ack's ts"
4748+ );
4749+ }
4750+
4751+ // -----------------------------------------------------------------------
4752+ // cursor restoration on budget exhaustion
4753+ // -----------------------------------------------------------------------
4754+
4755+ /// The cursor is advanced to `page.cursor` before the event loop runs. If the
4756+ /// poll-byte budget is exhausted mid-page, the loop breaks — but the cursor
4757+ /// must be restored to its pre-page value. Otherwise the next poll acks the
4758+ /// whole page to the relay, which deletes every event we skipped: permanent
4759+ /// message loss.
4760+ #[test]
4761+ fn a_budget_exhausted_mid_page_restores_the_cursor() {
4762+ let app = Skrepka;
4763+ let mut m = with_identity();
4764+ let peer = peer_hex(9);
4765+ m.contacts
4766+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4767+ m.cursor = 100;
4768+
4769+ // Two blobs, each at the per-blob ceiling (MAX_BLOB_LEN hex chars =
4770+ // MAX_BLOB_LEN/2 decoded bytes = 21 MiB). Individually they pass the
4771+ // per-blob size gate; together they exceed the 64 MiB page budget.
4772+ // The first passes the budget check (21 < 64) and fails decrypt (it's
4773+ // not a real blob); the second fails the budget check (21 + 21 + 21 > 64).
4774+ // Three blobs to be sure: 3 × 21 = 63 MiB (just under 64), so use four.
4775+ let big_hex = "ab".repeat(MAX_BLOB_LEN); // MAX_BLOB_LEN hex chars = MAX_BLOB_LEN/2 bytes
4776+ let page = PollResp {
4777+ events: (0..4)
4778+ .map(|_| PollEvent {
4779+ encrypted_blob: big_hex.clone(),
4780+ })
4781+ .collect(),
4782+ cursor: 200,
4783+ };
4784+ let _ = app.ingest_poll(&mut m, page);
4785+
4786+ assert_eq!(
4787+ m.cursor, 100,
4788+ "the cursor must be restored — the page was not fully processed"
4789+ );
4790+ assert!(
4791+ m.error.as_ref().is_some_and(|e| e.contains("oversized")),
4792+ "the user is told"
4793+ );
4794+ assert!(
4795+ m.messages.get(&peer).is_none_or(Vec::is_empty),
4796+ "nothing was stored from the oversized page"
4797+ );
4798+ }
4799+
4800+ // -----------------------------------------------------------------------
4801+ // send_text block check
4802+ // -----------------------------------------------------------------------
4803+
4804+ // -----------------------------------------------------------------------
4805+ // send_text body length check
4806+ // -----------------------------------------------------------------------
4807+
4808+ /// Blocking cuts a peer off in both directions. `send_text` must refuse to
4809+ /// enqueue a message to a blocked contact — otherwise the block only stops
4810+ /// their incoming mail while we keep sending them ours.
4811+ #[test]
4812+ fn send_text_to_a_blocked_peer_is_refused() {
4813+ let app = Skrepka;
4814+ let mut m = with_identity();
4815+ let peer = peer_hex(9);
4816+ let mut contact = Contact::new(peer.clone(), "Enemy".into(), 0);
4817+ contact.blocked = true;
4818+ m.contacts.insert(peer.clone(), contact);
4819+ m.active_peer = Some(peer.clone());
4820+ m.compose = "hello?".into();
4821+
4822+ let _ = app.update(Event::SendText, &mut m);
4823+
4824+ assert!(
4825+ m.outbox.is_empty(),
4826+ "no message is queued for a blocked peer"
4827+ );
4828+ assert!(
4829+ m.error.as_ref().is_some_and(|e| e.contains("blocked")),
4830+ "the user is told why"
4831+ );
4832+ // The compose buffer is not cleared — the user might want to unblock
4833+ // and resend, and losing their draft would be surprising.
4834+ assert_eq!(m.compose, "hello?");
4835+ }
4836+
4837+ /// A text body over `MAX_BODY_LEN` would be stored locally and sent to the
4838+ /// relay, but the recipient's `parse_payload` silently drops oversized text.
4839+ /// The sender would see it stuck as "sent" forever with no error.
4840+ #[test]
4841+ fn send_text_refuses_an_oversized_body() {
4842+ let app = Skrepka;
4843+ let mut m = with_identity();
4844+ let peer = peer_hex(9);
4845+ m.active_peer = Some(peer.clone());
4846+ m.compose = "x".repeat(protocol::MAX_BODY_LEN + 1);
4847+
4848+ let _ = app.update(Event::SendText, &mut m);
4849+
4850+ assert!(m.outbox.is_empty(), "nothing is queued");
4851+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty), "nothing stored");
4852+ assert!(!m.compose.is_empty(), "compose is preserved");
4853+ assert!(m.error.as_ref().is_some_and(|e| e.contains("too long")));
4854+ }
4855+
4856+ /// A stale error from a previous failed send must be cleared on the next
4857+ /// attempt, so the error display does not persist after the user fixes the
4858+ /// issue and sends again successfully.
4859+ #[test]
4860+ fn send_text_clears_a_stale_error_on_success() {
4861+ let app = Skrepka;
4862+ let mut m = with_identity();
4863+ let peer = peer_hex(9);
4864+ m.active_peer = Some(peer.clone());
4865+ // Simulate a prior failure that left an error behind.
4866+ m.error = Some("message too long".into());
4867+
4868+ m.compose = "hello".into();
4869+ let _ = app.update(Event::SendText, &mut m);
4870+
4871+ assert!(m.error.is_none(), "a successful send clears the stale error");
4872+ assert!(!m.outbox.is_empty(), "the message was queued");
4873+ }
4874+
4875+ // -----------------------------------------------------------------------
4876+ // SaveProfile field length checks
4877+ // -----------------------------------------------------------------------
4878+
4879+ #[test]
4880+ fn save_profile_rejects_an_oversized_display_name() {
4881+ let app = Skrepka;
4882+ let mut m = with_identity();
4883+
4884+ let _ = app.update(Event::SaveProfile {
4885+ display_name: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4886+ bio: String::new(),
4887+ photo: None,
4888+ }, &mut m);
4889+
4890+ assert!(m.outbox.is_empty(), "no broadcast queued");
4891+ assert!(m.profile.display_name.is_empty(), "local profile not updated");
4892+ assert!(m.error.as_ref().is_some_and(|e| e.contains("display name")));
4893+ }
4894+
4895+ #[test]
4896+ fn save_profile_rejects_an_oversized_bio() {
4897+ let app = Skrepka;
4898+ let mut m = with_identity();
4899+
4900+ let _ = app.update(Event::SaveProfile {
4901+ display_name: "Alice".into(),
4902+ bio: "x".repeat(protocol::MAX_BIO_LEN + 1),
4903+ photo: None,
4904+ }, &mut m);
4905+
4906+ assert!(m.outbox.is_empty(), "no broadcast queued");
4907+ assert!(m.profile.bio.is_empty(), "local profile not updated");
4908+ assert!(m.error.as_ref().is_some_and(|e| e.contains("bio")));
4909+ }
4910+
4911+ #[test]
4912+ fn save_profile_rejects_an_oversized_photo() {
4913+ let app = Skrepka;
4914+ let mut m = with_identity();
4915+
4916+ let _ = app.update(Event::SaveProfile {
4917+ display_name: "Alice".into(),
4918+ bio: String::new(),
4919+ photo: Some("x".repeat(protocol::MAX_PHOTO_LEN + 1)),
4920+ }, &mut m);
4921+
4922+ assert!(m.outbox.is_empty(), "no broadcast queued");
4923+ assert!(m.profile.photo.is_none(), "local profile not updated");
4924+ assert!(m.error.as_ref().is_some_and(|e| e.contains("photo")));
4925+ }
4926+
4927+ // -----------------------------------------------------------------------
4928+ // AddContact nickname length check
4929+ // -----------------------------------------------------------------------
4930+
4931+ #[test]
4932+ fn add_contact_rejects_an_oversized_nickname() {
4933+ let app = Skrepka;
4934+ let mut m = with_identity();
4935+ let peer = peer_hex(9);
4936+
4937+ let _ = app.update(Event::AddContact {
4938+ input: peer.clone(),
4939+ nickname: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4940+ }, &mut m);
4941+
4942+ assert!(!m.contacts.contains_key(&peer), "no contact created");
4943+ assert!(m.error.as_ref().is_some_and(|e| e.contains("nickname")));
4944+ }
4945+
4946+ // -----------------------------------------------------------------------
4947+ // FlushWatchdog unsticks a wedged send loop
4948+ // -----------------------------------------------------------------------
4949+
4950+ /// `flushing` is only cleared by `SendResult`. If the shell drops the HTTP
4951+ /// effect, the outbox is permanently stuck. The watchdog releases the guard
4952+ /// so the flush retries.
4953+ #[test]
4954+ fn the_flush_watchdog_unwedges_a_send_that_never_resolved() {
4955+ let app = Skrepka;
4956+ let mut m = with_identity();
4957+ m.token = Some("t".into());
4958+ let peer = peer_hex(9);
4959+ m.outbox.push_back(queued(&peer, "stuck"));
4960+
4961+ let _ = app.update(Event::StartFlush, &mut m);
4962+ assert!(m.flushing, "the send is in flight");
4963+ assert!(m.in_flight.is_some());
4964+
4965+ // The send never comes back. The watchdog fires, clears the stuck guard,
4966+ // and retries the flush — the message goes out again.
4967+ let mut cmd = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4968+
4969+ assert!(m.flushing, "a fresh send is in flight (the retry)");
4970+ assert!(m.in_flight.is_some(), "in_flight is set for the retry");
4971+ let batches = sent_batches(&mut cmd);
4972+ assert_eq!(batches.len(), 1, "the message is re-sent");
4973+ }
4974+
4975+ /// A watchdog that fires after the send already completed is a no-op.
4976+ #[test]
4977+ fn a_stale_flush_watchdog_leaves_a_completed_send_alone() {
4978+ let app = Skrepka;
4979+ let mut m = with_identity();
4980+ m.token = Some("t".into());
4981+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
4982+
4983+ let _ = app.update(Event::StartFlush, &mut m);
4984+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
4985+ assert!(!m.flushing);
4986+ assert!(m.outbox.is_empty());
4987+
4988+ // The watchdog for the completed send fires late.
4989+ let _ = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4990+ assert!(!m.flushing, "a completed send is not re-armed");
4991+ assert!(m.outbox.is_empty());
4992+ }
4993+
4994+ /// A late `SendResult` from a superseded send is dropped by the generation
4995+ /// check. The watchdog abandoned the first send and retried under a new
4996+ /// generation; the late result's generation no longer matches, so it cannot
4997+ /// pop the retry's items or clear its guard. The retry's own `SendResult`
4998+ /// is what eventually pops them.
4999+ #[test]
5000+ fn a_late_send_result_after_the_watchdog_is_dropped_by_generation() {
5001+ let app = Skrepka;
5002+ let mut m = with_identity();
5003+ m.token = Some("t".into());
5004+ let peer = peer_hex(9);
5005+ m.outbox.push_back(queued(&peer, "stuck"));
5006+
5007+ let _ = app.update(Event::StartFlush, &mut m);
5008+ let first_gen = m.flush_gen;
5009+ // The watchdog fires and retries the flush under a new generation.
5010+ let _ = app.update(Event::FlushWatchdog(first_gen), &mut m);
5011+ assert_eq!(m.outbox.len(), 1, "the item is still queued (re-sent)");
5012+ assert_ne!(m.flush_gen, first_gen, "the generation was bumped");
5013+ assert!(m.flushing, "the retry is in flight");
5014+
5015+ // The original send's result arrives late. Its generation no longer
5016+ // matches, so it is dropped — it cannot pop the retry's items or
5017+ // clear the retry's guard.
5018+ let _ = app.update(Event::SendResult(first_gen, Ok(ok_response(200))), &mut m);
5019+ assert!(m.flushing, "the retry's guard is untouched");
5020+ assert_eq!(m.outbox.len(), 1, "the late result popped nothing");
5021+
5022+ // The retry's own result arrives and pops the items.
5023+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
5024+ assert!(m.outbox.is_empty(), "the retry's result pops the items");
5025+ assert!(!m.flushing, "and the guard is released");
5026+ }
5027+}
core/src/bin/codegen.rsnew file+41
@@ -0,0 +1,41 @@
1+//! Generates the Swift (and other) type bindings for the shell from the core's
2+//! Event / ViewModel / Effect types via facet typegen.
3+
4+use std::path::PathBuf;
5+
6+use anyhow::Result;
7+use clap::{Parser, ValueEnum};
8+use crux_core::type_generation::facet::{Config, TypeRegistry};
9+use log::info;
10+
11+use skrepka_core::Skrepka;
12+
13+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
14+enum Language {
15+ Swift,
16+}
17+
18+#[derive(Parser)]
19+#[command(version, about, long_about = None)]
20+struct Args {
21+ #[arg(short, long, value_enum)]
22+ language: Language,
23+ #[arg(short, long)]
24+ output_dir: PathBuf,
25+}
26+
27+fn main() -> Result<()> {
28+ pretty_env_logger::init();
29+ let args = Args::parse();
30+
31+ let typegen = TypeRegistry::new().register_app::<Skrepka>()?.build()?;
32+ let config = Config::builder("Skrepka", &args.output_dir).build();
33+
34+ match args.language {
35+ Language::Swift => {
36+ info!("Generating Swift types");
37+ typegen.swift(&config)?;
38+ }
39+ }
40+ Ok(())
41+}
core/src/crypto.rsnew file+894
@@ -0,0 +1,894 @@
1+//! Byte-exact port of the skrepka message crypto (see PROTOCOL.md §3).
2+//!
3+//! Per-message: ephemeral X25519, HKDF-SHA256, XChaCha20-Poly1305, Ed25519
4+//! signature bound to the recipient, zstd-compressed plaintext.
5+//!
6+//! The Ed25519 secret key is the 64-byte libsodium form: `seed(32) || pub(32)`.
7+//! The X25519 keys are derived from Ed25519 the same way libsodium's
8+//! `crypto_sign_ed25519_{pk,sk}_to_curve25519` do, so blobs interoperate with
9+//! 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;
17+
18+use chacha20poly1305::aead::{Aead, Payload as AeadPayload};
19+use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
20+use curve25519_dalek::edwards::CompressedEdwardsY;
21+use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
22+use hkdf::Hkdf;
23+use rand_core::{CryptoRng, RngCore};
24+use sha2::{Digest, Sha256, Sha512};
25+use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
26+
27+/// HKDF `info` string (PROTOCOL.md §3). Derived from the wire version so a
28+/// future crypto revision that bumps the version byte also changes the KDF
29+/// label automatically — the two must always move together (N2).
30+const HKDF_INFO: &[u8] = b"skrepka-v2";
31+/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
32+const AUTH_TAG: &str = "skrepka-auth-v1:";
33+
34+/// Domain-separation tag for per-message signatures (PROTOCOL.md §3).
35+/// Prevents cross-protocol signature reuse: an auth challenge signature
36+/// begins with "skrepka-auth-v1:" while a message signature begins with
37+/// this tag, so the same key cannot sign in one context and have it
38+/// validate in another.
39+const MSG_TAG: &[u8] = b"skrepka-msg-v2:";
40+
41+/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
42+///
43+/// Version 0x02 adds AEAD associated data (AAD): the cleartext header
44+/// `version_byte || ephemeral_public || nonce` is authenticated alongside
45+/// the ciphertext, so a relay that tampers with any envelope field —
46+/// including swapping the `to` recipient — causes AEAD decryption to fail
47+/// before the blob is ever acked (N1). The recipient's Ed25519 public key
48+/// is also included in the AAD, binding the envelope `to` field to the
49+/// inner signed recipient, so a `to`-swap is detected as an AEAD failure
50+/// rather than a silent misdelivery.
51+const WIRE_VERSION: u8 = 2;
52+
53+/// Build the AAD for AEAD: `version || ephemeral_pub || nonce || recipient_pub`.
54+/// All envelope-level fields the relay can see are now cryptographically
55+/// bound to the ciphertext (N1).
56+fn aad(version: u8, eph_pub: &[u8; 32], nonce: &[u8; NONCE_LEN], recipient_pub: &[u8; 32]) -> Vec<u8> {
57+ let mut aad = Vec::with_capacity(1 + 32 + NONCE_LEN + 32);
58+ aad.push(version);
59+ aad.extend_from_slice(eph_pub);
60+ aad.extend_from_slice(nonce);
61+ aad.extend_from_slice(recipient_pub);
62+ aad
63+}
64+
65+pub const ED25519_SECRET_LEN: usize = 64;
66+pub const ED25519_PUBLIC_LEN: usize = 32;
67+pub const NONCE_LEN: usize = 24;
68+/// `1+32+24+32+64+4+16` bytes = version, eph pubkey, nonce, sender pub, sig,
69+/// compressed_len, AEAD tag. The 4-byte compressed_len field sits inside the AEAD
70+/// ciphertext. The version byte, ephemeral pubkey, nonce, and recipient pubkey are
71+/// AEAD AAD (v0x02).
72+pub const MIN_BLOB_LEN: usize = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + 16;
73+
74+/// Blob sizes are padded up to these boundaries so the on-wire length does not
75+/// reveal the exact plaintext (compressed) size to relays and network observers.
76+/// Below 65536 we round up to the nearest bucket; above it we round to the next
77+/// multiple of 65536 (MAX_BLOB_LEN is exactly 672 × 65536, so we never exceed it).
78+const PADDING_BUCKETS: [usize; 9] = [256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536];
79+
80+/// Round `actual` up to the nearest padding boundary.
81+fn padded_len(actual: usize) -> usize {
82+ if actual == 0 {
83+ return PADDING_BUCKETS[0];
84+ }
85+ for &bucket in &PADDING_BUCKETS {
86+ if actual <= bucket {
87+ return bucket;
88+ }
89+ }
90+ // Above 65536: round to the next multiple of 65536.
91+ let remainder = actual % 65536;
92+ if remainder == 0 {
93+ // D6: When actual is an exact multiple of 65536 above 65536,
94+ // returning `actual` would give zero padding, revealing the exact
95+ // compressed size. Always add at least one bucket of padding.
96+ actual + 65536
97+ } else {
98+ actual + 65536 - remainder
99+ }
100+}
101+/// Upper bound on a blob we will even attempt to decrypt, in *decoded* bytes —
102+/// rejected on length before it costs us a scalar mult or an AEAD pass.
103+///
104+/// Deliberately looser than the reference relay rather than equal to it: that
105+/// relay's `maxBlobLen` (41943040) bounds the *hex* string, i.e. ~20 MiB decoded,
106+/// so no blob it serves comes close to this. Sizing the client's ceiling to one
107+/// relay's would make us reject blobs a differently-configured relay may legally
108+/// deliver, and it buys no safety — the real bounds on attacker-controlled work
109+/// are `MAX_POLL_TOTAL_BYTES` (the page) and `MAX_PLAINTEXT_LEN` (the
110+/// decompression), both of which hold whatever this is set to.
111+pub const MAX_BLOB_LEN: usize = 42 * 1024 * 1024;
112+/// Upper bound on the *decompressed* plaintext inside a blob.
113+///
114+/// `zstd` frames advertise their content size, but a hostile peer can lie or
115+/// simply craft a small frame that expands to gigabytes. Without a cap the
116+/// decoder happily allocates all of it and iOS jetsams the app. Payloads are
117+/// JSON (text bodies, a base64 avatar at worst), so 1 MiB is generous.
118+pub const MAX_PLAINTEXT_LEN: usize = 1 << 20;
119+/// Upper bound on a server-supplied auth challenge we are willing to sign.
120+/// We sign whatever the relay hands us, so it must not be able to make us sign
121+/// an arbitrarily large blob of its choosing.
122+pub const MAX_CHALLENGE_LEN: usize = 256;
123+
124+#[derive(Debug, Clone, PartialEq, Eq)]
125+pub enum CryptoError {
126+ BadKeyLength,
127+ BlobTooShort,
128+ BlobTooLong,
129+ /// A 32-byte key that is not a valid curve point.
130+ InvalidPublicKey,
131+ /// The X25519 exchange produced an all-zero shared secret (low-order point).
132+ InvalidEphemeralKey,
133+ Encrypt,
134+ Decrypt,
135+ BadSignature,
136+ Decompress,
137+ ChallengeTooLong,
138+}
139+
140+impl std::fmt::Display for CryptoError {
141+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142+ let s = match self {
143+ CryptoError::BadKeyLength => "bad key length",
144+ CryptoError::BlobTooShort => "blob too short",
145+ CryptoError::BlobTooLong => "blob too long",
146+ CryptoError::InvalidPublicKey => "invalid public key",
147+ CryptoError::InvalidEphemeralKey => "invalid ephemeral key",
148+ CryptoError::Encrypt => "encryption failed",
149+ CryptoError::Decrypt => "decryption failed",
150+ CryptoError::BadSignature => "signature verification failed",
151+ CryptoError::Decompress => "decompression failed",
152+ CryptoError::ChallengeTooLong => "auth challenge too long",
153+ };
154+ f.write_str(s)
155+ }
156+}
157+
158+impl std::error::Error for CryptoError {}
159+
160+/// A generated identity: the 64-byte libsodium-form secret key. The public key
161+/// is the last 32 bytes.
162+///
163+/// `ZeroizeOnDrop` wipes the seed when the value goes away. `Identity` is cloned
164+/// freely (once per encrypt/decrypt), so each of those copies is wiped too.
165+///
166+/// The derived X25519 keys (`x25519_priv`, `x25519_pub`) are computed once at
167+/// construction and cached: `decrypt` is called per-blob in a poll page, and
168+/// recomputing the SHA-512 + Edwards decompress on every call is a measurable
169+/// CPU multiplier under a flood of small blobs. Both are `Zeroizing` so the
170+/// private half is wiped on drop alongside the seed.
171+#[derive(Clone, ZeroizeOnDrop)]
172+pub struct Identity {
173+ pub secret_key: [u8; ED25519_SECRET_LEN],
174+ x25519_priv: [u8; 32],
175+ x25519_pub: [u8; 32],
176+}
177+
178+impl Identity {
179+ /// Build from a 64-byte secret key (e.g. loaded from the Keychain).
180+ ///
181+ /// The trailing 32 bytes are *claimed* to be the public key; we re-derive it
182+ /// from the seed and check. A mismatched pair would otherwise sign under one
183+ /// key while advertising another — every message we send would be silently
184+ /// unverifiable at the recipient.
185+ pub fn from_secret_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
186+ if bytes.len() != ED25519_SECRET_LEN {
187+ return Err(CryptoError::BadKeyLength);
188+ }
189+ let mut secret_key = [0u8; ED25519_SECRET_LEN];
190+ secret_key.copy_from_slice(bytes);
191+
192+ let mut seed = Zeroizing::new([0u8; 32]);
193+ seed.copy_from_slice(&secret_key[..32]);
194+ let derived = SigningKey::from_bytes(&seed);
195+ if derived.verifying_key().as_bytes() != &secret_key[32..] {
196+ secret_key.zeroize();
197+ return Err(CryptoError::BadKeyLength);
198+ }
199+
200+ Identity::with_x25519(secret_key)
201+ }
202+
203+ /// Generate a fresh identity from a CSPRNG.
204+ pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Result<Self, CryptoError> {
205+ let mut seed = Zeroizing::new([0u8; 32]);
206+ rng.fill_bytes(&mut *seed);
207+ Self::from_seed(&seed)
208+ }
209+
210+ /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
211+ pub fn from_seed(seed: &[u8; 32]) -> Result<Self, CryptoError> {
212+ let signing = SigningKey::from_bytes(seed);
213+ let public = signing.verifying_key();
214+ let mut secret_key = [0u8; ED25519_SECRET_LEN];
215+ secret_key[..32].copy_from_slice(seed);
216+ secret_key[32..].copy_from_slice(public.as_bytes());
217+ Identity::with_x25519(secret_key)
218+ }
219+
220+ pub fn public_key(&self) -> [u8; ED25519_PUBLIC_LEN] {
221+ let mut pk = [0u8; ED25519_PUBLIC_LEN];
222+ pk.copy_from_slice(&self.secret_key[32..]);
223+ pk
224+ }
225+
226+ pub fn public_key_hex(&self) -> String {
227+ hex::encode(self.public_key())
228+ }
229+
230+ fn signing_key(&self) -> SigningKey {
231+ let mut seed = Zeroizing::new([0u8; 32]);
232+ seed.copy_from_slice(&self.secret_key[..32]);
233+ SigningKey::from_bytes(&seed)
234+ }
235+
236+ /// Compute the cached X25519 keys from the Ed25519 secret key.
237+ ///
238+ /// Q49: Also checks that the derived X25519 public key is not an all-zero
239+ /// (low-order) point. The probability of generating such a key is negligible,
240+ /// but if it ever happened every encryption to this identity would fail with
241+ /// `InvalidEphemeralKey` and the user would be unable to receive messages
242+ /// with no indication of why. Catching it at construction time gives a
243+ /// clear error instead.
244+ fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Result<Self, CryptoError> {
245+ let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
246+ let x25519_pub = x25519_dalek::x25519(x25519_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
247+ // D33: Return an error instead of just printing a warning when the
248+ // derived X25519 public key is all-zero (low-order point). The
249+ // probability of generating such a key is negligible, but if it ever
250+ // happened every encryption to this identity would fail with
251+ // `InvalidEphemeralKey` and the user would be unable to receive
252+ // messages with no indication of why. Catching it at construction time
253+ // gives a clear error instead.
254+ if x25519_pub.iter().all(|b| *b == 0) {
255+ return Err(CryptoError::InvalidPublicKey);
256+ }
257+ Ok(Identity {
258+ secret_key,
259+ x25519_priv,
260+ x25519_pub,
261+ })
262+ }
263+
264+ /// Cached X25519 private key derived from the Ed25519 seed (libsodium
265+ /// `crypto_sign_ed25519_sk_to_curve25519`). Avoids a per-decrypt SHA-512.
266+ fn x25519_private(&self) -> &[u8; 32] {
267+ &self.x25519_priv
268+ }
269+
270+ /// Cached X25519 public key derived from the Ed25519 public key (libsodium
271+ /// `crypto_sign_ed25519_pk_to_curve25519`). Avoids a per-decrypt decompress.
272+ fn x25519_public(&self) -> &[u8; 32] {
273+ &self.x25519_pub
274+ }
275+
276+ /// Sign the auth challenge: `ed25519(sk, "skrepka-auth-v1:" + host + ":" + challenge)`.
277+ /// `server_host` must already be the bare lowercased hostname.
278+ ///
279+ /// The challenge comes straight off the wire from a relay we have not
280+ /// authenticated, so its length is bounded before it goes into the hash.
281+ pub fn sign_challenge(&self, server_host: &str, challenge: &str) -> Result<String, CryptoError> {
282+ if challenge.len() > MAX_CHALLENGE_LEN {
283+ return Err(CryptoError::ChallengeTooLong);
284+ }
285+ let message = format!("{AUTH_TAG}{server_host}:{challenge}");
286+ let sig = self.signing_key().sign(message.as_bytes());
287+ Ok(hex::encode(sig.to_bytes()))
288+ }
289+}
290+
291+/// libsodium `crypto_sign_ed25519_pk_to_curve25519`: Edwards Y -> Montgomery u.
292+fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
293+ let compressed = CompressedEdwardsY(*ed_pub);
294+ let point = compressed
295+ .decompress()
296+ .ok_or(CryptoError::InvalidPublicKey)?;
297+ let montgomery = point.to_montgomery().to_bytes();
298+ // D3: A 32-byte all-zero input is the Ed25519 identity point, which
299+ // produces an all-zero Montgomery u-coordinate — a low-order point.
300+ // Reject it explicitly, separate from the x25519 shared-secret check.
301+ if montgomery.iter().all(|&b| b == 0) {
302+ return Err(CryptoError::InvalidPublicKey);
303+ }
304+ Ok(montgomery)
305+}
306+
307+/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
308+fn ed25519_sk_to_x25519(secret_key: &[u8; 64]) -> Zeroizing<[u8; 32]> {
309+ let mut h = Sha512::new();
310+ h.update(&secret_key[..32]);
311+ let mut digest = h.finalize();
312+ let mut scalar = Zeroizing::new([0u8; 32]);
313+ scalar.copy_from_slice(&digest[..32]);
314+ digest.as_mut_slice().zeroize();
315+ // X25519 clamp.
316+ scalar[0] &= 248;
317+ scalar[31] &= 127;
318+ scalar[31] |= 64;
319+ scalar
320+}
321+
322+/// Raw X25519 scalar multiplication (no hashing), as libsodium `crypto_scalarmult`.
323+///
324+/// An all-zero output means the peer supplied a low-order point: every scalar
325+/// then yields the same shared secret, so the "encryption" would be forgeable by
326+/// anyone. libsodium's `crypto_scalarmult` rejects this and so do we.
327+fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, CryptoError> {
328+ let shared = Zeroizing::new(x25519_dalek::x25519(*scalar, *point));
329+ if shared.iter().all(|b| *b == 0) {
330+ return Err(CryptoError::InvalidEphemeralKey);
331+ }
332+ Ok(shared)
333+}
334+
335+/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v2", 32).
336+fn derive_key(
337+ raw_secret: &[u8; 32],
338+ eph_pub: &[u8; 32],
339+ recip_x_pub: &[u8; 32],
340+) -> Zeroizing<[u8; 32]> {
341+ let mut salt = [0u8; 64];
342+ salt[..32].copy_from_slice(eph_pub);
343+ salt[32..].copy_from_slice(recip_x_pub);
344+ let hk = Hkdf::<Sha256>::new(Some(&salt), raw_secret);
345+ let mut okm = Zeroizing::new([0u8; 32]);
346+ hk.expand(HKDF_INFO, &mut *okm)
347+ .expect("32 is a valid length");
348+ okm
349+}
350+
351+fn compress(plaintext: &[u8]) -> Vec<u8> {
352+ // zstd frame with embedded content size; decodable without a capacity hint.
353+ zstd::encode_all(plaintext, 3).expect("zstd compression is infallible for in-memory input")
354+}
355+
356+/// Decompress a zstd frame, refusing to produce more than `MAX_PLAINTEXT_LEN`.
357+///
358+/// `zstd::decode_all` grows its output buffer to whatever the frame expands to,
359+/// which is attacker-chosen. Streaming through a `take`-limited reader bounds
360+/// the allocation instead: we read one byte past the cap so that a frame which
361+/// *would* overrun is detected rather than silently truncated.
362+fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
363+ let mut decoder = zstd::stream::Decoder::new(data).map_err(|_| CryptoError::Decompress)?;
364+ // Cap the decoder's window buffer so a crafted frame declaring a huge
365+ // windowLog (up to 27 = 128 MiB by default) cannot force a matching
366+ // allocation before the output-size cap even applies. 2^21 = 2 MiB is
367+ // comfortably above `MAX_PLAINTEXT_LEN` (1 MiB), so every legitimate frame
368+ // still decodes; frames declaring a larger window are rejected.
369+ decoder
370+ .window_log_max(21)
371+ .map_err(|_| CryptoError::Decompress)?;
372+ let mut out = Vec::with_capacity(4096);
373+ (&mut decoder)
374+ .take(MAX_PLAINTEXT_LEN as u64 + 1)
375+ .read_to_end(&mut out)
376+ .map_err(|_| CryptoError::Decompress)?;
377+ if out.len() > MAX_PLAINTEXT_LEN {
378+ return Err(CryptoError::Decompress);
379+ }
380+ Ok(out)
381+}
382+
383+/// Encrypt `plaintext` for `recipient_ed_pub` (32-byte Ed25519 pubkey).
384+/// Returns the on-wire blob: `eph_pub(32) || nonce(24) || ciphertext`.
385+pub fn encrypt(
386+ rng: &mut (impl RngCore + CryptoRng),
387+ sender: &Identity,
388+ recipient_ed_pub: &[u8; 32],
389+ plaintext: &[u8],
390+) -> Result<Vec<u8>, CryptoError> {
391+ // D28: Reject a plaintext over the cap before compressing and encrypting
392+ // — the recipient will reject it on decompression anyway, so the work
393+ // is wasted. CryptoError::Encrypt signals an encryption-side failure.
394+ if plaintext.len() > MAX_PLAINTEXT_LEN {
395+ return Err(CryptoError::Encrypt);
396+ }
397+ let sender_pub = sender.public_key();
398+
399+ // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
400+ let mut eph_priv = Zeroizing::new([0u8; 32]);
401+ rng.fill_bytes(&mut *eph_priv);
402+ let eph_pub = x25519_dalek::x25519(*eph_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
403+
404+ let recip_x = ed25519_pk_to_x25519(recipient_ed_pub)?;
405+ let raw_secret = x25519(&eph_priv, &recip_x)?;
406+ let key = derive_key(&raw_secret, &eph_pub, &recip_x);
407+
408+ let compressed = Zeroizing::new(compress(plaintext));
409+
410+ // Compute padding: round the full on-wire blob size up to a bucket boundary
411+ // so relays and network observers cannot tell the exact (compressed) length.
412+ // unpadded = 1 (version) + 32 (eph_pub) + 24 (nonce) + inner + 16 (AEAD tag)
413+ // = 1 + 32 + 24 + (32 + 64 + 4 + compressed.len()) + 16
414+ // = 173 + compressed.len()
415+ let unpadded_blob_len = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + compressed.len() + 16;
416+ let target = padded_len(unpadded_blob_len);
417+ let pad_len = target - unpadded_blob_len;
418+
419+ let mut padding = Zeroizing::new(vec![0u8; pad_len]);
420+ rng.fill_bytes(&mut padding);
421+
422+ let compressed_len_bytes = (compressed.len() as u32).to_be_bytes();
423+
424+ // Sign recipient_pub || compressed_len_bytes || compressed || padding
425+ // (i.e. recipient_pub || everything after the 96-byte header in inner).
426+ let mut signed = Zeroizing::new(Vec::with_capacity(MSG_TAG.len() + 32 + 4 + compressed.len() + padding.len()));
427+ signed.extend_from_slice(MSG_TAG);
428+ signed.extend_from_slice(recipient_ed_pub);
429+ signed.extend_from_slice(&compressed_len_bytes);
430+ signed.extend_from_slice(&compressed);
431+ signed.extend_from_slice(&padding);
432+ let signature = sender.signing_key().sign(&signed);
433+
434+ // inner = sender_pub(32) || sig(64) || compressed_len(4) || compressed || padding
435+ //
436+ // `Zeroizing` so the sender's pubkey, signature, and the compressed plaintext
437+ // are wiped from memory even on the AEAD failure path — the `?` below returns
438+ // early, and a plain `Vec` would be dropped without zeroization. Every other
439+ // sensitive intermediate here (`compressed`, `signed`, `padding`) is already
440+ // `Zeroizing`; `inner` holds copies of all of them plus the sender identity.
441+ let mut inner = Zeroizing::new(Vec::with_capacity(32 + 64 + 4 + compressed.len() + padding.len()));
442+ inner.extend_from_slice(&sender_pub);
443+ inner.extend_from_slice(&signature.to_bytes());
444+ inner.extend_from_slice(&compressed_len_bytes);
445+ inner.extend_from_slice(&compressed);
446+ inner.extend_from_slice(&padding);
447+
448+ let mut nonce = [0u8; NONCE_LEN];
449+ rng.fill_bytes(&mut nonce);
450+ let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
451+ // AAD binds the envelope header (version, ephemeral pubkey, nonce) and
452+ // the recipient identity to the ciphertext (N1). A relay that swaps the
453+ // `to` field causes the wrong recipient to fail AEAD decryption, and the
454+ // original recipient's message stays in the mailbox instead of being
455+ // silently misdelivered.
456+ let aad_buf = aad(WIRE_VERSION, &eph_pub, &nonce, recipient_ed_pub);
457+ let payload = AeadPayload {
458+ msg: inner.as_ref(),
459+ aad: &aad_buf,
460+ };
461+ let ciphertext = cipher
462+ .encrypt(XNonce::from_slice(&nonce), payload)
463+ .map_err(|_| CryptoError::Encrypt)?;
464+ inner.zeroize();
465+
466+ let mut blob = Vec::with_capacity(1 + 32 + NONCE_LEN + ciphertext.len());
467+ blob.push(WIRE_VERSION);
468+ blob.extend_from_slice(&eph_pub);
469+ blob.extend_from_slice(&nonce);
470+ blob.extend_from_slice(&ciphertext);
471+ Ok(blob)
472+}
473+
474+/// Result of decrypting a blob: the plaintext and the sender's Ed25519 pubkey (hex).
475+///
476+/// `plaintext` is `Zeroizing<Vec<u8>>` so the decrypted message content is wiped
477+/// from memory when the value is dropped, not left in freed memory for a heap
478+/// inspection or a jetsam snapshot. `Deref` makes `&dec.plaintext` work as `&[u8]`
479+/// via auto-deref, so callers that only read the bytes need no change.
480+#[derive(Debug, Clone)]
481+pub struct Decrypted {
482+ pub plaintext: Zeroizing<Vec<u8>>,
483+ pub sender_hex: String,
484+}
485+
486+/// Decrypt a blob addressed to `recipient`. Verifies the sender's signature.
487+pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoError> {
488+ if blob.len() < MIN_BLOB_LEN {
489+ return Err(CryptoError::BlobTooShort);
490+ }
491+ if blob.len() > MAX_BLOB_LEN {
492+ return Err(CryptoError::BlobTooLong);
493+ }
494+ // Version byte — reject any blob we don't know how to handle.
495+ if blob[0] != WIRE_VERSION {
496+ return Err(CryptoError::Decrypt);
497+ }
498+ let mut eph_pub = [0u8; 32];
499+ eph_pub.copy_from_slice(&blob[1..33]);
500+ let mut nonce = [0u8; NONCE_LEN];
501+ nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]);
502+ let ciphertext = &blob[33 + NONCE_LEN..];
503+
504+ let recip_x_priv = recipient.x25519_private();
505+ let raw_secret = x25519(recip_x_priv, &eph_pub)?;
506+ let recip_x_pub = recipient.x25519_public();
507+ let key = derive_key(&raw_secret, &eph_pub, recip_x_pub);
508+
509+ let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
510+ // Reconstruct the same AAD used during encryption: the envelope header
511+ // and the recipient identity. This binds the envelope `to` field to the
512+ // ciphertext — a relay that swapped `to` causes AEAD failure here, not
513+ // a silent misdelivery (N1).
514+ let recip_pub = recipient.public_key();
515+ let aad_buf = aad(blob[0], &eph_pub, &nonce, &recip_pub);
516+ let payload = AeadPayload { msg: ciphertext, aad: &aad_buf };
517+ let mut inner = Zeroizing::new(
518+ cipher
519+ .decrypt(XNonce::from_slice(&nonce), payload)
520+ .map_err(|_| CryptoError::Decrypt)?,
521+ );
522+
523+ if inner.len() < 32 + 64 + 4 {
524+ inner.zeroize();
525+ return Err(CryptoError::BlobTooShort);
526+ }
527+ let mut sender_pub = [0u8; 32];
528+ sender_pub.copy_from_slice(&inner[..32]);
529+ let mut sig_bytes = [0u8; 64];
530+ sig_bytes.copy_from_slice(&inner[32..96]);
531+
532+ let compressed_len = u32::from_be_bytes([
533+ inner[96],
534+ inner[97],
535+ inner[98],
536+ inner[99],
537+ ]) as usize;
538+ if compressed_len > inner.len().saturating_sub(100) {
539+ inner.zeroize();
540+ return Err(CryptoError::Decrypt);
541+ }
542+ let compressed = &inner[100..100 + compressed_len];
543+
544+ // Verify signature over recipient_pub || inner[96..]
545+ // (covers compressed_len_bytes || compressed || padding).
546+ let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
547+ let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
548+ let mut signed = Zeroizing::new(Vec::with_capacity(MSG_TAG.len() + 32 + (inner.len() - 96)));
549+ signed.extend_from_slice(MSG_TAG);
550+ signed.extend_from_slice(&recipient.public_key());
551+ signed.extend_from_slice(&inner[96..]);
552+ verifying
553+ .verify_strict(&signed, &signature)
554+ .map_err(|_| CryptoError::BadSignature)?;
555+
556+ let plaintext = decompress(compressed)?;
557+ // `inner` and `signed` hold the decrypted sender identity and message
558+ // content; zeroize them explicitly on the success path too, not just on
559+ // errors (the `Zeroizing` wrapper handles drop, but being explicit here
560+ // keeps the success and error paths symmetric).
561+ inner.zeroize();
562+ signed.zeroize();
563+ Ok(Decrypted {
564+ plaintext: Zeroizing::new(plaintext),
565+ sender_hex: hex::encode(sender_pub),
566+ })
567+}
568+
569+#[cfg(test)]
570+mod tests {
571+ use super::*;
572+ use rand_chacha::rand_core::SeedableRng;
573+ use rand_chacha::ChaCha20Rng;
574+
575+ fn rng(seed: u64) -> ChaCha20Rng {
576+ ChaCha20Rng::seed_from_u64(seed)
577+ }
578+
579+ #[test]
580+ fn identity_pub_is_last_32_bytes_and_consistent() {
581+ let id = Identity::generate(&mut rng(1)).unwrap();
582+ assert_eq!(&id.secret_key[32..], &id.public_key());
583+ // Re-deriving from the seed yields the same public key.
584+ let mut seed = [0u8; 32];
585+ seed.copy_from_slice(&id.secret_key[..32]);
586+ let id2 = Identity::from_seed(&seed).unwrap();
587+ assert_eq!(id.public_key(), id2.public_key());
588+ }
589+
590+ /// A 64-byte key whose trailing pubkey doesn't match its seed would sign
591+ /// under one identity while claiming another.
592+ #[test]
593+ fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
594+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
595+ assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
596+
597+ let mut tampered = id.secret_key;
598+ tampered[32] ^= 0x01; // claim a different public key
599+ assert!(matches!(
600+ Identity::from_secret_bytes(&tampered),
601+ Err(CryptoError::BadKeyLength)
602+ ));
603+ }
604+
605+ #[test]
606+ fn round_trip_recovers_plaintext_and_sender() {
607+ let alice = Identity::generate(&mut rng(10)).unwrap();
608+ let bob = Identity::generate(&mut rng(20)).unwrap();
609+ let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
610+
611+ let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
612+ assert!(blob.len() >= MIN_BLOB_LEN);
613+
614+ let out = decrypt(&bob, &blob).unwrap();
615+ assert_eq!(out.plaintext.as_slice(), msg);
616+ assert_eq!(out.sender_hex, alice.public_key_hex());
617+ }
618+
619+ #[test]
620+ fn wrong_recipient_cannot_decrypt() {
621+ let alice = Identity::generate(&mut rng(1)).unwrap();
622+ let bob = Identity::generate(&mut rng(2)).unwrap();
623+ let eve = Identity::generate(&mut rng(3)).unwrap();
624+ let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
625+ assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
626+ }
627+
628+ #[test]
629+ fn tampered_ciphertext_is_rejected() {
630+ let alice = Identity::generate(&mut rng(1)).unwrap();
631+ let bob = Identity::generate(&mut rng(2)).unwrap();
632+ let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
633+ let last = blob.len() - 1;
634+ blob[last] ^= 0xff;
635+ assert!(decrypt(&bob, &blob).is_err());
636+ }
637+
638+ #[test]
639+ fn blob_layout_offsets() {
640+ let alice = Identity::generate(&mut rng(1)).unwrap();
641+ let bob = Identity::generate(&mut rng(2)).unwrap();
642+ let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
643+ // Version byte at offset 0.
644+ assert_eq!(blob[0], WIRE_VERSION);
645+ // eph_pub occupies bytes 1..33 and equals x25519 basepoint * eph_priv;
646+ // we can't recompute eph_priv, but the nonce region must differ run-to-run.
647+ let blob2 = encrypt(&mut rng(8), &alice, &bob.public_key(), b"x").unwrap();
648+ assert_ne!(&blob[1..33], &blob2[1..33], "ephemeral pubkey must be random");
649+ assert_ne!(
650+ &blob[33..33 + NONCE_LEN],
651+ &blob2[33..33 + NONCE_LEN],
652+ "nonce must be random"
653+ );
654+ }
655+
656+ #[test]
657+ fn too_short_blob_errors() {
658+ let bob = Identity::generate(&mut rng(2)).unwrap();
659+ assert_eq!(
660+ decrypt(&bob, &[0u8; 10]).unwrap_err(),
661+ CryptoError::BlobTooShort
662+ );
663+ }
664+
665+ /// An oversized blob is rejected on length alone — before it costs us a
666+ /// scalar mult, an AEAD pass, or a decompression.
667+ #[test]
668+ fn too_long_blob_errors() {
669+ let bob = Identity::generate(&mut rng(2)).unwrap();
670+ let huge = vec![0u8; MAX_BLOB_LEN + 1];
671+ assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
672+ }
673+
674+ /// The decompression bomb: a small zstd frame that expands past the cap must
675+ /// error out rather than allocate its way to a jetsam kill.
676+ #[test]
677+ fn a_decompression_bomb_is_refused() {
678+ let bomb = compress(&vec![0u8; MAX_PLAINTEXT_LEN + 1]);
679+ assert!(
680+ bomb.len() < 4096,
681+ "the frame really is small ({} bytes)",
682+ bomb.len()
683+ );
684+ assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
685+
686+ // ...and the same bomb sent through encrypt is rejected before
687+ // compression/encryption work is done (D28).
688+ let alice = Identity::generate(&mut rng(1)).unwrap();
689+ let bob = Identity::generate(&mut rng(2)).unwrap();
690+ assert_eq!(
691+ encrypt(
692+ &mut rng(3),
693+ &alice,
694+ &bob.public_key(),
695+ &vec![0u8; MAX_PLAINTEXT_LEN + 1],
696+ )
697+ .unwrap_err(),
698+ CryptoError::Encrypt,
699+ );
700+ }
701+
702+ /// A plaintext right at the cap still round-trips: the bound is inclusive.
703+ #[test]
704+ fn a_plaintext_at_the_cap_still_round_trips() {
705+ let alice = Identity::generate(&mut rng(1)).unwrap();
706+ let bob = Identity::generate(&mut rng(2)).unwrap();
707+ let msg = vec![7u8; MAX_PLAINTEXT_LEN];
708+ let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
709+ assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
710+ }
711+
712+ /// A low-order ephemeral point makes every recipient derive the same shared
713+ /// secret, so the sender's "encryption" is forgeable. Reject it.
714+ #[test]
715+ fn a_low_order_ephemeral_point_is_rejected() {
716+ let bob = Identity::generate(&mut rng(2)).unwrap();
717+ // eph_pub = 0 is the canonical low-order point; x25519 with it yields
718+ // an all-zero shared secret for any scalar.
719+ let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
720+ blob[0] = WIRE_VERSION;
721+ blob[1..33].fill(0);
722+ assert_eq!(
723+ decrypt(&bob, &blob).unwrap_err(),
724+ CryptoError::InvalidEphemeralKey
725+ );
726+ }
727+
728+ /// A short message must be padded so the blob size lands on a bucket boundary.
729+ #[test]
730+ fn blob_is_padded_to_bucket_size() {
731+ let alice = Identity::generate(&mut rng(1)).unwrap();
732+ let bob = Identity::generate(&mut rng(2)).unwrap();
733+
734+ // Short message → should round up to the smallest bucket (256).
735+ let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
736+ assert!(
737+ PADDING_BUCKETS.contains(&blob_small.len()),
738+ "blob len {} is not a bucket boundary",
739+ blob_small.len()
740+ );
741+ assert_eq!(blob_small.len(), 256);
742+
743+ // A larger message should also land on a bucket boundary.
744+ let large_msg = vec![0u8; 300];
745+ let blob_large = encrypt(&mut rng(8), &alice, &bob.public_key(), &large_msg).unwrap();
746+ assert!(
747+ PADDING_BUCKETS.contains(&blob_large.len()) || blob_large.len().is_multiple_of(65536),
748+ "blob len {} is not a bucket boundary",
749+ blob_large.len()
750+ );
751+ }
752+
753+ /// The same plaintext encrypted with different RNG seeds must produce the
754+ /// same blob size (same bucket) but different blob contents (random padding).
755+ #[test]
756+ fn padding_is_random_per_message() {
757+ let alice = Identity::generate(&mut rng(1)).unwrap();
758+ let bob = Identity::generate(&mut rng(2)).unwrap();
759+ let msg = b"hello bob, this is a test message";
760+
761+ let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
762+ let blob2 = encrypt(&mut rng(200), &alice, &bob.public_key(), msg).unwrap();
763+
764+ // Same bucket → same size.
765+ assert_eq!(blob1.len(), blob2.len(), "same plaintext must have same blob size");
766+
767+ // Different RNG → different ephemeral key, nonce, and padding → different bytes.
768+ assert_ne!(&blob1[..], &blob2[..], "different RNG must produce different blobs");
769+
770+ // Both must decrypt correctly.
771+ assert_eq!(decrypt(&bob, &blob1).unwrap().plaintext.as_slice(), msg);
772+ assert_eq!(decrypt(&bob, &blob2).unwrap().plaintext.as_slice(), msg);
773+ }
774+
775+ /// The compressed_len field must be parsed correctly so the decompressor
776+ /// never sees trailing padding bytes.
777+ #[test]
778+ fn compressed_len_field_is_parsed_correctly() {
779+ let alice = Identity::generate(&mut rng(1)).unwrap();
780+ let bob = Identity::generate(&mut rng(2)).unwrap();
781+
782+ // Normal round-trip.
783+ let msg = b"round trip test with some content";
784+ let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), msg).unwrap();
785+ assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg);
786+
787+ // A message whose compressed size happens to produce a blob that lands
788+ // exactly on a bucket boundary (zero padding). We can't easily engineer
789+ // this, but we can verify the decrypt path works with the actual data.
790+ // Just verify that every bucket-sized blob round-trips.
791+ for size in [1usize, 10, 100, 200, 500, 1000, 5000] {
792+ let m = vec![0xABu8; size];
793+ let b = encrypt(&mut rng(42), &alice, &bob.public_key(), &m).unwrap();
794+ assert_eq!(decrypt(&bob, &b).unwrap().plaintext.as_slice(), m.as_slice());
795+ }
796+ }
797+
798+ /// A recipient key that isn't a curve point can't be encrypted to, and the
799+ /// error says so rather than blaming decryption.
800+ #[test]
801+ fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
802+ let alice = Identity::generate(&mut rng(1)).unwrap();
803+ let not_a_point = [0xabu8; 32];
804+ assert_eq!(
805+ encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
806+ CryptoError::InvalidPublicKey
807+ );
808+ }
809+
810+ #[test]
811+ fn sk_to_x25519_clamps() {
812+ let id = Identity::generate(&mut rng(5)).unwrap();
813+ let scalar = ed25519_sk_to_x25519(&id.secret_key);
814+ assert_eq!(scalar[0] & 7, 0);
815+ assert_eq!(scalar[31] & 64, 64);
816+ assert_eq!(scalar[31] & 128, 0);
817+ }
818+
819+ #[test]
820+ fn challenge_signature_is_deterministic_and_host_bound() {
821+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
822+ let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
823+ let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
824+ let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
825+ assert_eq!(a, b, "Ed25519 is deterministic");
826+ assert_ne!(a, c, "signature is bound to the host");
827+ assert_eq!(a.len(), 128, "64-byte sig in hex");
828+ }
829+
830+ /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
831+ #[test]
832+ fn an_oversized_challenge_is_not_signed() {
833+ let id = Identity::from_seed(&[7u8; 32]).unwrap();
834+ let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
835+ assert_eq!(
836+ id.sign_challenge("relay.example.com", &huge).unwrap_err(),
837+ CryptoError::ChallengeTooLong
838+ );
839+ // The bound itself is inclusive.
840+ let at_cap = "a".repeat(MAX_CHALLENGE_LEN);
841+ assert!(id.sign_challenge("relay.example.com", &at_cap).is_ok());
842+ }
843+
844+ /// A blob encrypted to Bob cannot be decrypted by Charlie — the key
845+ /// derivation itself binds the recipient (the AEAD key is a function
846+ /// of the recipient's private key and the ephemeral public key). The
847+ /// AAD additionally authenticates the recipient pubkey, but the tag
848+ /// check would fail even without it, because Charlie derives a
849+ /// different key. The AAD is defense-in-depth: it makes the binding
850+ /// explicit rather than implicit in the KDF.
851+ #[test]
852+ fn wrong_recipient_cannot_decrypt_aead_fails() {
853+ let alice = Identity::generate(&mut rng(1)).unwrap();
854+ let bob = Identity::generate(&mut rng(2)).unwrap();
855+ let charlie = Identity::generate(&mut rng(3)).unwrap();
856+
857+ let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"hi bob").unwrap();
858+ assert_eq!(
859+ decrypt(&charlie, &blob).unwrap_err(),
860+ CryptoError::Decrypt,
861+ "wrong recipient must fail AEAD — key derivation binds the recipient"
862+ );
863+ }
864+
865+ /// Tampering with the version byte causes failure via the explicit
866+ /// version check (`blob[0] != WIRE_VERSION`) before the AEAD even runs.
867+ /// The AAD includes the version as defense-in-depth, but the explicit
868+ /// check fires first.
869+ #[test]
870+ fn tampered_version_byte_is_rejected() {
871+ let alice = Identity::generate(&mut rng(1)).unwrap();
872+ let bob = Identity::generate(&mut rng(2)).unwrap();
873+ let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
874+ blob[0] ^= 0xff;
875+ assert_eq!(
876+ decrypt(&bob, &blob).unwrap_err(),
877+ CryptoError::Decrypt,
878+ "version byte tampering is rejected"
879+ );
880+ }
881+
882+ /// Tampering with the ephemeral pubkey causes key derivation to produce
883+ /// a different key, so the AEAD tag check fails. The AAD additionally
884+ /// authenticates the eph_pub, but the KDF mismatch is what actually
885+ /// catches this.
886+ #[test]
887+ fn tampered_ephemeral_pubkey_is_rejected() {
888+ let alice = Identity::generate(&mut rng(1)).unwrap();
889+ let bob = Identity::generate(&mut rng(2)).unwrap();
890+ let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
891+ blob[5] ^= 0x01;
892+ assert!(decrypt(&bob, &blob).is_err());
893+ }
894+}
core/src/ffi.rsnew file+96
@@ -0,0 +1,96 @@
1+//! The FFI surface the Swift shell calls, via BoltFFI. Mirrors the Crux
2+//! 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.
14+
15+#![allow(clippy::used_underscore_items)]
16+
17+use std::panic::{catch_unwind, AssertUnwindSafe};
18+
19+use crux_core::{
20+ bridge::{Bridge, EffectId},
21+ Core,
22+};
23+
24+use crate::Skrepka;
25+
26+/// The main interface used by the shell.
27+pub struct CoreFFI {
28+ core: Bridge<Skrepka>,
29+}
30+
31+impl Default for CoreFFI {
32+ fn default() -> Self {
33+ Self::new()
34+ }
35+}
36+
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+///
43+/// NOTE: A panic mid-`update` leaves the `Model` in a partially-mutated state.
44+/// The next `update` call operates on this potentially-corrupted state. A
45+/// proper fix requires snapshotting the Model before `update` and restoring on
46+/// panic, or marking the Bridge as poisoned. This is an accepted limitation.
47+fn guard(f: impl FnOnce() -> Vec<u8>) -> Vec<u8> {
48+ catch_unwind(AssertUnwindSafe(f)).unwrap_or_default()
49+}
50+
51+#[boltffi::export]
52+impl CoreFFI {
53+ #[must_use]
54+ pub fn new() -> Self {
55+ Self {
56+ core: Bridge::new(Core::new()),
57+ }
58+ }
59+
60+ /// Send an event to the app and return the resulting effects (bincode).
61+ /// Empty on a serialization failure — see the module docs.
62+ #[must_use]
63+ pub fn update(&self, data: &[u8]) -> Vec<u8> {
64+ guard(|| {
65+ let mut effects = Vec::new();
66+ match self.core.update(data, &mut effects) {
67+ Ok(()) => effects,
68+ Err(_) => Vec::new(),
69+ }
70+ })
71+ }
72+
73+ /// Resolve an effect request and return the next batch of effects.
74+ #[must_use]
75+ pub fn resolve(&self, id: u32, data: &[u8]) -> Vec<u8> {
76+ guard(|| {
77+ let mut effects = Vec::new();
78+ match self.core.resolve(EffectId(id), data, &mut effects) {
79+ Ok(()) => effects,
80+ Err(_) => Vec::new(),
81+ }
82+ })
83+ }
84+
85+ /// Get the current serialized `ViewModel`.
86+ #[must_use]
87+ pub fn view(&self) -> Vec<u8> {
88+ guard(|| {
89+ let mut view_model = Vec::new();
90+ match self.core.view(&mut view_model) {
91+ Ok(()) => view_model,
92+ Err(_) => Vec::new(),
93+ }
94+ })
95+ }
96+}
core/src/lib.rsnew file+10
@@ -0,0 +1,10 @@
1+//! Skrepka shared core — portable business logic for the iOS (and future) clients.
2+
3+pub mod app;
4+pub mod crypto;
5+pub mod ffi;
6+pub mod model;
7+pub mod phonemic;
8+pub mod protocol;
9+
10+pub use app::Skrepka;
core/src/model.rsnew file+255
@@ -0,0 +1,255 @@
1+//! Domain types: persisted state (stored in the kv capability as JSON) and the
2+//! `ViewModel` types the Swift shell renders.
3+
4+use std::sync::Arc;
5+
6+use facet::Facet;
7+use serde::{Deserialize, Serialize};
8+
9+use crate::phonemic;
10+
11+// ---------------------------------------------------------------------------
12+// Persisted state (serialized to the kv store as JSON)
13+// ---------------------------------------------------------------------------
14+
15+pub const DEFAULT_SERVER_URL: &str = "http://localhost:8080";
16+
17+/// Cap on locally retained messages per conversation (PROTOCOL.md §9: clients
18+/// SHOULD age out local message history). Both the in-memory `Vec` and the
19+/// `messages:<peer>` kv blob are trimmed to this many most-recent messages.
20+pub const MAX_MESSAGES_PER_PEER: usize = 1000;
21+
22+/// Every persisted struct is `#[serde(default)]`.
23+///
24+/// A kv blob that fails to deserialize is indistinguishable from an absent key
25+/// (see `app::parse_kv`), and the next mutation overwrites it — so a field added
26+/// in a later release would silently wipe the user's contacts, outbox, or
27+/// history on first launch. Defaulting the missing fields keeps old blobs
28+/// loadable across schema evolution instead.
29+#[derive(Serialize, Deserialize, Clone, Debug)]
30+#[serde(default)]
31+pub struct Settings {
32+ pub server_url: String,
33+}
34+
35+impl Default for Settings {
36+ fn default() -> Self {
37+ Settings {
38+ server_url: DEFAULT_SERVER_URL.to_string(),
39+ }
40+ }
41+}
42+
43+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
44+#[serde(default)]
45+pub struct OwnProfile {
46+ pub display_name: String,
47+ pub bio: String,
48+ /// base64-encoded image, or `None`.
49+ pub photo: Option<String>,
50+}
51+
52+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
53+#[serde(default)]
54+pub struct Contact {
55+ pub pubkey: String,
56+ pub nickname: String,
57+ pub display_name: String,
58+ pub bio: String,
59+ pub photo: Option<String>,
60+ pub blocked: bool,
61+ /// `ts` of the last applied profile message (drop older replays).
62+ pub last_profile_ts: i64,
63+ /// `ts` of the last applied delivery.ack (drop older replays).
64+ pub last_ack_ts: i64,
65+ pub added_at: i64,
66+}
67+
68+impl Contact {
69+ pub fn new(pubkey: String, nickname: String, added_at: i64) -> Self {
70+ Contact {
71+ pubkey,
72+ nickname,
73+ display_name: String::new(),
74+ bio: String::new(),
75+ photo: None,
76+ blocked: false,
77+ last_profile_ts: 0,
78+ last_ack_ts: 0,
79+ added_at,
80+ }
81+ }
82+
83+ /// Best human label: nickname, else shared display name, else truncated @p.
84+ pub fn label(&self) -> String {
85+ if !self.nickname.is_empty() {
86+ self.nickname.clone()
87+ } else if !self.display_name.is_empty() {
88+ self.display_name.clone()
89+ } else {
90+ trunc_ob(&self.pubkey)
91+ }
92+ }
93+}
94+
95+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
96+#[serde(default)]
97+pub struct StoredMessage {
98+ pub id: String,
99+ pub body: String,
100+ pub ts: i64,
101+ pub outgoing: bool,
102+ /// Only meaningful for outgoing messages.
103+ pub delivered: bool,
104+}
105+
106+/// Transient send failures one outbox item may absorb before it is given up on.
107+///
108+/// Without a ceiling the head of the outbox is retried forever, and because the
109+/// outbox is a strict FIFO an item that can never be delivered blocks every
110+/// message queued behind it — permanently.
111+pub const MAX_OUTBOX_RETRIES: u32 = 10;
112+
113+/// ...and a wall-clock bound on the same thing, for the case where the retries
114+/// come in slowly enough that the counter alone would take days to run out. 24h.
115+pub const OUTBOX_TTL_MS: i64 = 86_400_000;
116+
117+/// A queued outbound payload awaiting encryption + send.
118+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
119+#[serde(default)]
120+pub struct OutboxItem {
121+ pub recipient: String,
122+ /// The serialized plaintext payload JSON (encrypted only at send time).
123+ pub envelope_json: Arc<String>,
124+ /// `Some("profile")` for a profile broadcast, `None` for everything else.
125+ ///
126+ /// A profile is *state*, not an event: only the latest one matters, so
127+ /// `SaveProfile` overwrites the pending item for a recipient rather than
128+ /// queueing another. Without the marker there is nothing to overwrite by, and
129+ /// five quick profile edits mean five payloads (photo and all) per contact.
130+ pub kind: Option<String>,
131+ /// Transient send failures this item has absorbed so far.
132+ pub retries: u32,
133+ /// `now_ms()` when this item was first put on the wire, or `0` if it never
134+ /// has been. The TTL runs from here, so an item queued during a week offline
135+ /// still gets its full retry budget once the network comes back.
136+ pub first_attempt: i64,
137+}
138+
139+impl OutboxItem {
140+ pub fn new(recipient: String, envelope_json: Arc<String>) -> Self {
141+ OutboxItem {
142+ recipient,
143+ envelope_json,
144+ kind: None,
145+ retries: 0,
146+ first_attempt: 0,
147+ }
148+ }
149+
150+ /// A profile broadcast — the one payload kind that supersedes rather than
151+ /// accumulates (see `kind`).
152+ pub fn profile(recipient: String, envelope_json: Arc<String>) -> Self {
153+ OutboxItem {
154+ kind: Some("profile".to_string()),
155+ ..OutboxItem::new(recipient, envelope_json)
156+ }
157+ }
158+
159+ pub fn is_profile(&self) -> bool {
160+ self.kind.as_deref() == Some("profile")
161+ }
162+
163+ /// Has this item exhausted its retry budget or outlived the TTL?
164+ ///
165+ /// `first_attempt == 0` means it has never been sent (or was queued by a build
166+ /// that predates the field), so the TTL has nothing to measure from and only
167+ /// the counter applies.
168+ pub fn is_expired(&self, now: i64) -> bool {
169+ self.retries >= MAX_OUTBOX_RETRIES
170+ || (self.first_attempt > 0 && now.saturating_sub(self.first_attempt) > OUTBOX_TTL_MS)
171+ }
172+}
173+
174+// ---------------------------------------------------------------------------
175+// @p helpers
176+// ---------------------------------------------------------------------------
177+
178+/// Full @p rendering of a hex pubkey. Falls back to the input for anything that
179+/// isn't an even-length hex string — the @p encoding is only defined on byte
180+/// *pairs*, so an odd-length input has no syllable spelling at all.
181+pub fn hex_to_ob(hex_key: &str) -> String {
182+ hex::decode(hex_key)
183+ .ok()
184+ .and_then(|bytes| phonemic::to_ob(&bytes))
185+ .unwrap_or_else(|| hex_key.to_string())
186+}
187+
188+/// Truncated @p: first two and last two syllables (`a-b-…-y-z`).
189+pub fn trunc_ob(hex_key: &str) -> String {
190+ let ob = hex_to_ob(hex_key);
191+ let parts: Vec<&str> = ob.split('-').collect();
192+ if parts.len() > 4 {
193+ format!(
194+ "{}-{}-…-{}-{}",
195+ parts[0],
196+ parts[1],
197+ parts[parts.len() - 2],
198+ parts[parts.len() - 1]
199+ )
200+ } else {
201+ ob
202+ }
203+}
204+
205+// ---------------------------------------------------------------------------
206+// ViewModel (rendered by the shell)
207+// ---------------------------------------------------------------------------
208+
209+#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
210+pub struct ProfileVM {
211+ pub display_name: String,
212+ pub bio: String,
213+ /// base64 image or empty.
214+ pub photo: String,
215+}
216+
217+#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
218+pub struct ContactVM {
219+ pub pubkey: String,
220+ pub name: String,
221+ pub ob: String,
222+ pub photo: String,
223+ pub blocked: bool,
224+ pub last_message: String,
225+ pub last_ts: i64,
226+}
227+
228+#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
229+pub struct MessageVM {
230+ pub id: String,
231+ pub body: String,
232+ pub ts: i64,
233+ pub outgoing: bool,
234+ pub delivered: bool,
235+}
236+
237+#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
238+pub struct ViewModel {
239+ /// "conversations" | "chat" | "add_contact" | "settings" | "edit_profile"
240+ pub page: String,
241+ pub has_identity: bool,
242+ pub my_pubkey_hex: String,
243+ pub my_pubkey_ob: String,
244+ /// "offline" | "connecting" | "online"
245+ pub conn_status: String,
246+ pub server_url: String,
247+ pub profile: ProfileVM,
248+ pub contacts: Vec<ContactVM>,
249+ pub active_peer: String,
250+ pub active_peer_name: String,
251+ pub active_peer_ob: String,
252+ pub active_peer_blocked: bool,
253+ pub messages: Vec<MessageVM>,
254+ pub error: String,
255+}
core/src/phonemic.rsnew file+289
@@ -0,0 +1,289 @@
1+//! Urbit-style `@p` syllable encoding for public keys (port of Phonemic.fs).
2+//!
3+//! Each byte pair `[hi, lo]` encodes as `prefixes[hi] ++ suffixes[lo]` (6 chars);
4+//! pairs are joined with `-`. A 32-byte key -> 16 six-char syllables.
5+
6+use std::collections::HashMap;
7+use std::sync::OnceLock;
8+
9+const PREFIXES: [&str; 256] = [
10+ "doz", "mar", "bin", "wan", "sam", "lit", "sig", "hid", "fid", "lis", "sog", "dir", "wac",
11+ "sab", "wis", "sib", "rig", "sol", "dop", "mod", "fog", "lid", "hop", "dar", "dor", "lor",
12+ "hod", "fol", "rin", "tog", "sil", "mir", "hol", "pas", "lac", "rov", "liv", "dal", "sat",
13+ "lib", "tab", "han", "tic", "pid", "tor", "bol", "fos", "dot", "los", "dil", "for", "pil",
14+ "ram", "tir", "win", "tad", "bic", "dif", "roc", "wid", "bis", "das", "mid", "lop", "ril",
15+ "nar", "dap", "mol", "san", "loc", "nov", "sit", "nid", "tip", "sic", "rop", "wit", "nat",
16+ "pan", "min", "rit", "pod", "mot", "tam", "tol", "sav", "pos", "nap", "nop", "som", "fin",
17+ "fon", "ban", "mor", "wor", "sip", "ron", "nor", "bot", "wic", "soc", "wat", "dol", "mag",
18+ "pic", "dav", "bid", "bal", "tim", "tas", "mal", "lig", "siv", "tag", "pad", "sal", "div",
19+ "dac", "tan", "sid", "fab", "tar", "mon", "ran", "nis", "wol", "mis", "pal", "las", "dis",
20+ "map", "rab", "tob", "rol", "lat", "lon", "nod", "nav", "fig", "nom", "nib", "pag", "sop",
21+ "ral", "bil", "had", "doc", "rid", "moc", "pac", "rav", "rip", "fal", "tod", "til", "tin",
22+ "hap", "mic", "fan", "pat", "tac", "lab", "mog", "sim", "son", "pin", "lom", "ric", "tap",
23+ "fir", "has", "bos", "bat", "poc", "hac", "tid", "hav", "sap", "lin", "dib", "hos", "dab",
24+ "bit", "bar", "rac", "par", "lod", "dos", "bor", "toc", "hil", "mac", "tom", "dig", "fil",
25+ "fas", "mit", "hob", "har", "mig", "hin", "rad", "mas", "hal", "rag", "lag", "fad", "top",
26+ "mop", "hab", "nil", "nos", "mil", "fop", "fam", "dat", "nol", "din", "hat", "nac", "ris",
27+ "fot", "rib", "hoc", "nim", "lar", "fit", "wal", "rap", "sar", "nal", "mos", "lan", "don",
28+ "dan", "lad", "dov", "riv", "bac", "pol", "lap", "tal", "pit", "nam", "bon", "ros", "ton",
29+ "fod", "pon", "sov", "noc", "sor", "lav", "mat", "mip", "fip",
30+];
31+
32+const SUFFIXES: [&str; 256] = [
33+ "zod", "nec", "bud", "wes", "sev", "per", "sut", "let", "ful", "pen", "syt", "dur", "wep",
34+ "ser", "wyl", "sun", "ryp", "syx", "dyr", "nup", "heb", "peg", "lup", "dep", "dys", "put",
35+ "lug", "hec", "ryt", "tyv", "syd", "nex", "lun", "mep", "lut", "sep", "pes", "del", "sul",
36+ "ped", "tem", "led", "tul", "met", "wen", "byn", "hex", "feb", "pyl", "dul", "het", "mev",
37+ "rut", "tyl", "wyd", "tep", "bes", "dex", "sef", "wyc", "bur", "der", "nep", "pur", "rys",
38+ "reb", "den", "nut", "sub", "pet", "rul", "syn", "reg", "tyd", "sup", "sem", "wyn", "rec",
39+ "meg", "net", "sec", "mul", "nym", "tev", "web", "sum", "mut", "nyx", "rex", "teb", "fus",
40+ "hep", "ben", "mus", "wyx", "sym", "sel", "ruc", "dec", "wex", "syr", "wet", "dyl", "myn",
41+ "mes", "det", "bet", "bel", "tux", "tug", "myr", "pel", "syp", "ter", "meb", "set", "dut",
42+ "deg", "tex", "sur", "fel", "tud", "nux", "rux", "ren", "wyt", "nub", "med", "lyt", "dus",
43+ "neb", "rum", "tyn", "seg", "lyx", "pun", "res", "red", "fun", "rev", "ref", "mec", "ted",
44+ "rus", "bex", "leb", "dux", "ryn", "num", "pyx", "ryg", "ryx", "fep", "tyr", "tus", "tyc",
45+ "leg", "nem", "fer", "mer", "ten", "lus", "nus", "syl", "tec", "mex", "pub", "rym", "tuc",
46+ "fyl", "lep", "deb", "ber", "mug", "hut", "tun", "byl", "sud", "pem", "dev", "lur", "def",
47+ "bus", "bep", "run", "mel", "pex", "dyt", "byt", "typ", "lev", "myl", "wed", "duc", "fur",
48+ "fex", "nul", "luc", "len", "ner", "lex", "rup", "ned", "lec", "ryd", "lyd", "fen", "wel",
49+ "nyd", "hus", "rel", "rud", "nes", "hes", "fet", "des", "ret", "dun", "ler", "nyr", "seb",
50+ "hul", "ryl", "lud", "rem", "lys", "fyn", "wer", "ryc", "sug", "nys", "nyl", "lyn", "dyn",
51+ "dem", "lux", "fed", "sed", "bec", "mun", "lyr", "tes", "mud", "nyt", "byr", "sen", "weg",
52+ "fyr", "mur", "tel", "rep", "teg", "pec", "nel", "nev", "fes",
53+];
54+
55+/// Reverse tables for decoding. Built once, on first `from_ob`.
56+///
57+/// Correctness rests on both tables being duplicate-free: a `HashMap` keeps the
58+/// *last* of any repeated syllable, where the linear scan this replaced returned
59+/// the *first*. `tables_are_full_and_distinct` pins that invariant down.
60+static PREFIX_MAP: OnceLock<HashMap<&'static str, u8>> = OnceLock::new();
61+static SUFFIX_MAP: OnceLock<HashMap<&'static str, u8>> = OnceLock::new();
62+
63+fn prefix_map() -> &'static HashMap<&'static str, u8> {
64+ PREFIX_MAP.get_or_init(|| {
65+ PREFIXES
66+ .iter()
67+ .enumerate()
68+ .map(|(i, &s)| (s, i as u8))
69+ .collect()
70+ })
71+}
72+
73+fn suffix_map() -> &'static HashMap<&'static str, u8> {
74+ SUFFIX_MAP.get_or_init(|| {
75+ SUFFIXES
76+ .iter()
77+ .enumerate()
78+ .map(|(i, &s)| (s, i as u8))
79+ .collect()
80+ })
81+}
82+
83+fn prefix_index(s: &str) -> Option<u8> {
84+ prefix_map().get(s).copied()
85+}
86+
87+fn suffix_index(s: &str) -> Option<u8> {
88+ suffix_map().get(s).copied()
89+}
90+
91+/// Encode bytes as a hyphen-joined `@p` string. `None` for an odd-length input.
92+///
93+/// A syllable *is* a byte pair, so an odd trailing byte has no spelling. Padding
94+/// it with suffix 0 (as this used to) made the encoding non-injective: `[x]` and
95+/// `[x, 0]` produced the same @p, and `from_ob` decoded both back to `[x, 0]`.
96+/// Keys are always 32 bytes, so no caller is affected — but a display helper
97+/// must not quietly invent a byte.
98+pub fn to_ob(bytes: &[u8]) -> Option<String> {
99+ // P1: Empty input produces an empty string, which is a valid but
100+ // meaningless @p — return None instead so the round-trip is symmetric.
101+ if bytes.is_empty() {
102+ return None;
103+ }
104+ if !bytes.len().is_multiple_of(2) {
105+ return None;
106+ }
107+ Some(
108+ bytes
109+ .chunks_exact(2)
110+ .map(|pair| format!("{}{}", PREFIXES[pair[0] as usize], SUFFIXES[pair[1] as usize]))
111+ .collect::<Vec<_>>()
112+ .join("-"),
113+ )
114+}
115+
116+/// Decode a `@p` string back to bytes. Returns `None` if any syllable is invalid.
117+/// Input is case-insensitive; the syllable tables are lowercase.
118+pub fn from_ob(s: &str) -> Option<Vec<u8>> {
119+ let lowered = s.to_lowercase();
120+ let parts: Vec<&str> = lowered.split('-').filter(|p| !p.is_empty()).collect();
121+ if parts.is_empty() {
122+ return None;
123+ }
124+ let mut out = Vec::with_capacity(parts.len() * 2);
125+ for part in parts {
126+ // `len` is in bytes, so the ASCII check is what makes the 3-byte slices
127+ // below char-boundary-safe on arbitrary user input.
128+ if part.len() != 6 || !part.is_ascii() {
129+ return None;
130+ }
131+ let hi = prefix_index(&part[0..3])?;
132+ let lo = suffix_index(&part[3..6])?;
133+ out.push(hi);
134+ out.push(lo);
135+ }
136+ Some(out)
137+}
138+
139+/// Check that `bytes` is a 32-byte, well-formed Ed25519 public key, and return
140+/// its lowercase hex.
141+///
142+/// Length alone is not enough: `crypto::encrypt` decompresses the recipient key
143+/// to its Montgomery form, so 32 bytes that aren't a curve point would be
144+/// accepted as a contact and then fail on *every* send to them.
145+fn valid_pubkey_hex(bytes: &[u8]) -> Option<String> {
146+ let key: [u8; 32] = bytes.try_into().ok()?;
147+ ed25519_dalek::VerifyingKey::from_bytes(&key).ok()?;
148+ Some(hex::encode(key))
149+}
150+
151+/// Parse a contact identifier given as either a `@p` string or 64-char hex.
152+/// Returns the lowercase hex of a valid 32-byte Ed25519 public key.
153+pub fn try_parse_pubkey(input: &str) -> Option<String> {
154+ let trimmed = input.trim();
155+ // Try @p first (contains hyphens or non-hex syllables), then hex.
156+ if let Some(hex) = from_ob(trimmed).as_deref().and_then(valid_pubkey_hex) {
157+ return Some(hex);
158+ }
159+ if let Ok(bytes) = hex::decode(trimmed) {
160+ return valid_pubkey_hex(&bytes);
161+ }
162+ None
163+}
164+
165+#[cfg(test)]
166+mod tests {
167+ use super::*;
168+
169+ /// The name always claimed distinctness; now the decode path depends on it.
170+ /// A duplicate syllable would collapse a map entry, so a table of 256 that
171+ /// maps to fewer than 256 keys is the exact failure this must catch.
172+ #[test]
173+ fn tables_are_full_and_distinct() {
174+ assert_eq!(PREFIXES.len(), 256);
175+ assert_eq!(SUFFIXES.len(), 256);
176+ assert_eq!(PREFIXES[0], "doz");
177+ assert_eq!(SUFFIXES[0], "zod");
178+ assert_eq!(prefix_map().len(), 256, "prefix table has a duplicate");
179+ assert_eq!(suffix_map().len(), 256, "suffix table has a duplicate");
180+ }
181+
182+ /// Every index must survive encode -> decode. Guards the map against being
183+ /// built off-by-one or with a truncated cast.
184+ #[test]
185+ fn every_syllable_index_round_trips() {
186+ for i in 0..=255u8 {
187+ assert_eq!(prefix_index(PREFIXES[i as usize]), Some(i));
188+ assert_eq!(suffix_index(SUFFIXES[i as usize]), Some(i));
189+ }
190+ assert_eq!(prefix_index("zod"), None, "suffix must not decode as prefix");
191+ assert_eq!(suffix_index("doz"), None, "prefix must not decode as suffix");
192+ }
193+
194+ #[test]
195+ fn round_trip_32_bytes() {
196+ let key: Vec<u8> = (0u8..32).collect();
197+ let ob = to_ob(&key).unwrap();
198+ assert_eq!(ob.split('-').count(), 16, "16 syllables for 32 bytes");
199+ assert_eq!(from_ob(&ob), Some(key));
200+ }
201+
202+ #[test]
203+ fn zero_key_is_dozzod_repeated() {
204+ let ob = to_ob(&[0u8; 32]).unwrap();
205+ assert!(ob.starts_with("dozzod-dozzod"));
206+ }
207+
208+ /// A syllable is a byte *pair*. Padding an odd trailing byte with suffix 0
209+ /// made `to_ob` non-injective: `[7]` and `[7, 0]` spelled the same, and
210+ /// `from_ob` decoded both to `[7, 0]`.
211+ #[test]
212+ fn odd_length_input_has_no_spelling() {
213+ assert_eq!(to_ob(&[7u8]), None);
214+ assert_eq!(to_ob(&[7u8, 0]), Some("hidzod".to_string()));
215+ assert_eq!(to_ob(&[]), None);
216+ }
217+
218+ /// A real Ed25519 public key. `try_parse_pubkey` now checks that a key is a
219+ /// curve point, so tests can't use an arbitrary byte pattern.
220+ fn real_key() -> [u8; 32] {
221+ crate::crypto::Identity::from_seed(&[7u8; 32]).unwrap().public_key()
222+ }
223+
224+ /// 32 bytes that decode fine but are *not* on the curve — the case that used
225+ /// to be accepted as a contact and then failed on every send to it.
226+ const NOT_A_POINT: [u8; 32] = [0xab; 32];
227+
228+ #[test]
229+ fn try_parse_accepts_hex_and_ob() {
230+ let key = real_key();
231+ let hex_str = hex::encode(key);
232+ let ob = to_ob(&key).unwrap();
233+ assert_eq!(try_parse_pubkey(&hex_str), Some(hex_str.clone()));
234+ assert_eq!(try_parse_pubkey(&ob), Some(hex_str.clone()));
235+ assert_eq!(try_parse_pubkey(&hex_str.to_uppercase()), Some(hex_str));
236+ }
237+
238+ #[test]
239+ fn try_parse_rejects_wrong_length_and_garbage() {
240+ assert_eq!(try_parse_pubkey("deadbeef"), None);
241+ assert_eq!(try_parse_pubkey("not-a-key"), None);
242+ assert_eq!(try_parse_pubkey(""), None);
243+ }
244+
245+ #[test]
246+ fn try_parse_rejects_a_32_byte_key_that_is_not_a_curve_point() {
247+ assert!(
248+ ed25519_dalek::VerifyingKey::from_bytes(&NOT_A_POINT).is_err(),
249+ "fixture must really be off-curve"
250+ );
251+ // Both spellings of it: raw hex, and the @p it round-trips through.
252+ let ob = to_ob(&NOT_A_POINT).unwrap();
253+ assert_eq!(try_parse_pubkey(&hex::encode(NOT_A_POINT)), None);
254+ assert_eq!(try_parse_pubkey(&ob), None);
255+ // The syllable encoding itself is still lossless — only the key check fails.
256+ assert_eq!(from_ob(&ob), Some(NOT_A_POINT.to_vec()));
257+ }
258+
259+ #[test]
260+ fn ob_parsing_is_case_insensitive() {
261+ let key = real_key();
262+ let hex_str = hex::encode(key);
263+ let ob = to_ob(&key).unwrap();
264+ assert_eq!(from_ob(&ob.to_uppercase()), Some(key.to_vec()));
265+ assert_eq!(try_parse_pubkey(&ob.to_uppercase()), Some(hex_str.clone()));
266+ // Mixed case, as a QR scan or a paste from a title-cased field might give.
267+ let mixed: String = ob
268+ .chars()
269+ .enumerate()
270+ .map(|(i, c)| {
271+ if i.is_multiple_of(2) {
272+ c.to_ascii_uppercase()
273+ } else {
274+ c
275+ }
276+ })
277+ .collect();
278+ assert_eq!(try_parse_pubkey(&mixed), Some(hex_str));
279+ }
280+
281+ /// `part.len()` is a *byte* count, so a 6-byte syllable holding a multi-byte
282+ /// char would slice mid-character and panic. Reachable from the AddContact field.
283+ #[test]
284+ fn multibyte_input_is_rejected_not_panicked_on() {
285+ assert_eq!(from_ob("abéxx"), None);
286+ assert_eq!(try_parse_pubkey("abéxx"), None);
287+ assert_eq!(try_parse_pubkey("ünïcødé-abéxx"), None);
288+ }
289+}
core/src/protocol.rsnew file+355
@@ -0,0 +1,355 @@
1+//! Plaintext payload (de)serialization and the wire envelope (PROTOCOL.md §4).
2+//!
3+//! Plaintext payloads are UTF-8 JSON with a `type` discriminator and an int64
4+//! millisecond `ts`. Unknown types are ignored (return `None`).
5+
6+use serde::{Deserialize, Serialize};
7+use base64::prelude::*;
8+use base64::Engine;
9+
10+/// Caps on the attacker-chosen fields of an incoming payload.
11+///
12+/// The AEAD proves the blob came from *someone*, not that they are polite: a
13+/// peer can put a 40 MiB string in `body` or `bio` and we would store it, render
14+/// it, and write it back to disk on every subsequent message. Anything over the
15+/// cap is treated like any other malformed payload — dropped silently
16+/// (PROTOCOL.md §4).
17+pub const MAX_BODY_LEN: usize = 64 * 1024;
18+pub const MAX_DISPLAY_NAME_LEN: usize = 128;
19+pub const MAX_BIO_LEN: usize = 1024;
20+pub const MAX_PHOTO_LEN: usize = 64 * 1024;
21+/// Message ids are UUIDs; they are stored, compared, and echoed back in acks.
22+pub const MAX_ID_LEN: usize = 128;
23+/// Ids in one `delivery.ack`. We scan the conversation once per id, so an
24+/// unbounded list is O(n·m) work a peer can dictate for free.
25+pub const MAX_ACK_IDS: usize = 1024;
26+
27+/// A decrypted plaintext payload. `photo` stays base64 (as on the wire).
28+#[derive(Debug, Clone, PartialEq, Eq)]
29+pub enum Payload {
30+ Text {
31+ id: String,
32+ body: String,
33+ },
34+ DeliveryAck {
35+ ack_ids: Vec<String>,
36+ },
37+ Profile {
38+ display_name: Option<String>,
39+ bio: Option<String>,
40+ photo: Option<String>,
41+ },
42+}
43+
44+/// A parsed payload plus its sender-supplied timestamp (ms).
45+#[derive(Debug, Clone, PartialEq, Eq)]
46+pub struct ParsedPayload {
47+ pub ts: i64,
48+ pub payload: Payload,
49+}
50+
51+/// The server-visible envelope: only the recipient and the opaque blob.
52+#[derive(Debug, Clone, Serialize, Deserialize)]
53+pub struct Envelope {
54+ pub to: String,
55+ #[serde(rename = "encryptedBlob")]
56+ pub encrypted_blob: String,
57+}
58+
59+/// Serialize a payload to the plaintext JSON sent inside the AEAD ciphertext.
60+pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
61+ let value = match payload {
62+ Payload::Text { id, body } => serde_json::json!({
63+ "type": "text",
64+ "id": id,
65+ "body": body,
66+ "ts": ts,
67+ }),
68+ Payload::DeliveryAck { ack_ids } => serde_json::json!({
69+ "type": "delivery.ack",
70+ "ack_ids": ack_ids,
71+ "ts": ts,
72+ }),
73+ Payload::Profile {
74+ display_name,
75+ bio,
76+ photo,
77+ } => {
78+ let mut obj = serde_json::json!({
79+ "type": "profile",
80+ "ts": ts,
81+ });
82+ // D13: Only include the field when present — absent means
83+ // "don't change", while present (even empty) means "set to this".
84+ if let Some(name) = display_name {
85+ obj["display_name"] = serde_json::Value::String(name.clone());
86+ }
87+ if let Some(b) = bio {
88+ obj["bio"] = serde_json::Value::String(b.clone());
89+ }
90+ // Omit `photo` entirely when there isn't one, rather than sending an
91+ // explicit null: "no photo" and "field absent" mean the same thing to
92+ // a reader, and the null costs bytes in every broadcast.
93+ if let Some(photo) = photo {
94+ obj["photo"] = serde_json::Value::String(photo.clone());
95+ }
96+ obj
97+ }
98+ };
99+ value.to_string()
100+}
101+
102+/// D14: Filter out control characters, bidirectional control characters, and
103+/// zero-width characters from a received string. Applied on the receiving side
104+/// (parse_payload) so the sender's input is preserved as-is on send, but the
105+/// recipient is protected from invisible character tricks.
106+///
107+/// Filters:
108+/// - Control characters U+0000–U+001F (except U+0009 tab, U+000A newline,
109+/// U+000D carriage return)
110+/// - Bidirectional control characters (U+200E LRM, U+200F RLM, U+202A–U+202E,
111+/// U+2066–U+2069)
112+/// - Zero-width characters (U+200B, U+200C, U+200D, U+FEFF)
113+fn sanitize_text(s: &str) -> String {
114+ s.chars()
115+ .filter(|c| {
116+ let cp = *c as u32;
117+ // Control characters U+0000–U+001F, except tab/newline/CR
118+ if cp <= 0x001F && cp != 0x0009 && cp != 0x000A && cp != 0x000D {
119+ return false;
120+ }
121+ // Bidirectional control characters
122+ if matches!(cp, 0x200E | 0x200F | 0x202A..=0x202E | 0x2066..=0x2069) {
123+ return false;
124+ }
125+ // Zero-width characters
126+ if matches!(cp, 0x200B | 0x200C | 0x200D | 0xFEFF) {
127+ return false;
128+ }
129+ true
130+ })
131+ .collect()
132+}
133+
134+/// Parse a plaintext payload. Returns `None` for unknown/malformed types so the
135+/// caller silently ignores them (forward compatibility, PROTOCOL.md §4).
136+pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
137+ let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
138+ let ts = v.get("ts").and_then(|t| t.as_i64()).unwrap_or(0);
139+ let ty = v.get("type")?.as_str()?;
140+ let payload = match ty {
141+ "text" => {
142+ let id = v.get("id")?.as_str()?.to_string();
143+ let body = sanitize_text(v.get("body")?.as_str()?);
144+ if id.len() > MAX_ID_LEN || body.len() > MAX_BODY_LEN {
145+ return None;
146+ }
147+ Payload::Text { id, body }
148+ }
149+ "delivery.ack" => {
150+ let raw = v.get("ack_ids")?.as_array()?;
151+ if raw.len() > MAX_ACK_IDS {
152+ return None;
153+ }
154+ let ack_ids: Vec<String> = raw
155+ .iter()
156+ .filter_map(|x| x.as_str().map(str::to_string))
157+ .collect();
158+ // Re-check after filtering: a peer could pack the array with
159+ // non-string junk that passes the raw count but leaves more
160+ // real ids than the cap allows after filtering. The raw check
161+ // above is an early out; this is the authoritative one.
162+ if ack_ids.len() > MAX_ACK_IDS || ack_ids.iter().any(|id| id.len() > MAX_ID_LEN) {
163+ return None;
164+ }
165+ Payload::DeliveryAck { ack_ids }
166+ }
167+ "profile" => {
168+ // D13: Use Option<String> — None when the field is absent (don't
169+ // change the recipient's cached value), Some(value) when present
170+ // (even if empty, meaning "set to this value").
171+ let display_name = v.get("display_name").and_then(|x| x.as_str()).map(sanitize_text);
172+ let bio = v.get("bio").and_then(|x| x.as_str()).map(sanitize_text);
173+ let photo = v
174+ .get("photo")
175+ .and_then(|x| x.as_str())
176+ .map(str::to_string);
177+ if display_name.as_ref().is_some_and(|d| d.chars().count() > MAX_DISPLAY_NAME_LEN)
178+ || bio.as_ref().is_some_and(|b| b.chars().count() > MAX_BIO_LEN)
179+ || photo.as_ref().is_some_and(|p| p.len() > MAX_PHOTO_LEN)
180+ || photo.as_ref().is_some_and(|p| {
181+ // Reject a photo that isn't valid base64 — a peer can put
182+ // any string in the field, and the shell attempts to decode
183+ // it as an image on every render.
184+ BASE64_STANDARD.decode(p).is_err()
185+ })
186+ {
187+ return None;
188+ }
189+ Payload::Profile {
190+ display_name,
191+ bio,
192+ photo,
193+ }
194+ }
195+ _ => return None,
196+ };
197+ Some(ParsedPayload { ts, payload })
198+}
199+
200+#[cfg(test)]
201+mod tests {
202+ use super::*;
203+
204+ fn reparse(p: &Payload, ts: i64) -> ParsedPayload {
205+ let json = serialize_payload(p, ts);
206+ parse_payload(json.as_bytes()).unwrap()
207+ }
208+
209+ #[test]
210+ fn text_round_trip() {
211+ let p = Payload::Text {
212+ id: "id-1".into(),
213+ body: "hello".into(),
214+ };
215+ let out = reparse(&p, 1700000000000);
216+ assert_eq!(out.ts, 1700000000000);
217+ assert_eq!(out.payload, p);
218+ }
219+
220+ #[test]
221+ fn ack_round_trip() {
222+ let p = Payload::DeliveryAck {
223+ ack_ids: vec!["a".into(), "b".into()],
224+ };
225+ assert_eq!(reparse(&p, 42).payload, p);
226+ }
227+
228+ #[test]
229+ fn profile_round_trip_with_and_without_photo() {
230+ let with = Payload::Profile {
231+ display_name: Some("Alice".into()),
232+ bio: Some("writing".into()),
233+ photo: Some("aGVsbG8=".into()),
234+ };
235+ assert_eq!(reparse(&with, 1).payload, with);
236+ let without = Payload::Profile {
237+ display_name: Some("Bob".into()),
238+ bio: Some("".into()),
239+ photo: None,
240+ };
241+ assert_eq!(reparse(&without, 1).payload, without);
242+ }
243+
244+ #[test]
245+ fn unknown_type_is_ignored() {
246+ let json = r#"{"type":"read.receipt","ts":1}"#;
247+ assert!(parse_payload(json.as_bytes()).is_none());
248+ }
249+
250+ #[test]
251+ fn text_missing_field_is_rejected() {
252+ let json = r#"{"type":"text","id":"x","ts":1}"#;
253+ assert!(parse_payload(json.as_bytes()).is_none());
254+ }
255+
256+ #[test]
257+ fn missing_ts_defaults_to_zero() {
258+ let json = r#"{"type":"text","id":"x","body":"y"}"#;
259+ assert_eq!(parse_payload(json.as_bytes()).unwrap().ts, 0);
260+ }
261+
262+ /// A peer can put anything inside the AEAD; the caps are what stop a 40 MiB
263+ /// `body` from being stored, rendered, and rewritten to disk forever.
264+ #[test]
265+ fn oversized_fields_are_dropped() {
266+ let big = "x".repeat(MAX_BODY_LEN + 1);
267+ let json = serialize_payload(
268+ &Payload::Text {
269+ id: "x".into(),
270+ body: big,
271+ },
272+ 1,
273+ );
274+ assert!(parse_payload(json.as_bytes()).is_none(), "body over cap");
275+
276+ let long_bio = serialize_payload(
277+ &Payload::Profile {
278+ display_name: Some("A".into()),
279+ bio: Some("b".repeat(MAX_BIO_LEN + 1)),
280+ photo: None,
281+ },
282+ 1,
283+ );
284+ assert!(parse_payload(long_bio.as_bytes()).is_none(), "bio over cap");
285+
286+ let long_name = serialize_payload(
287+ &Payload::Profile {
288+ display_name: Some("n".repeat(MAX_DISPLAY_NAME_LEN + 1)),
289+ bio: Some(String::new()),
290+ photo: None,
291+ },
292+ 1,
293+ );
294+ assert!(parse_payload(long_name.as_bytes()).is_none(), "name over cap");
295+
296+ let big_photo = serialize_payload(
297+ &Payload::Profile {
298+ display_name: Some(String::new()),
299+ bio: Some(String::new()),
300+ photo: Some("p".repeat(MAX_PHOTO_LEN + 1)),
301+ },
302+ 1,
303+ );
304+ assert!(parse_payload(big_photo.as_bytes()).is_none(), "photo over cap");
305+
306+ let many_acks = serialize_payload(
307+ &Payload::DeliveryAck {
308+ ack_ids: vec!["a".to_string(); MAX_ACK_IDS + 1],
309+ },
310+ 1,
311+ );
312+ assert!(parse_payload(many_acks.as_bytes()).is_none(), "ack list over cap");
313+ }
314+
315+ /// The caps are inclusive: a payload sitting exactly at the limit still works.
316+ #[test]
317+ fn fields_at_the_cap_still_parse() {
318+ let at_cap = serialize_payload(
319+ &Payload::Text {
320+ id: "x".into(),
321+ body: "x".repeat(MAX_BODY_LEN),
322+ },
323+ 1,
324+ );
325+ assert!(parse_payload(at_cap.as_bytes()).is_some());
326+ }
327+
328+ /// `"photo": null` is noise on every profile broadcast; absent means absent.
329+ #[test]
330+ fn a_photoless_profile_omits_the_field() {
331+ let json = serialize_payload(
332+ &Payload::Profile {
333+ display_name: Some("A".into()),
334+ bio: Some("B".into()),
335+ photo: None,
336+ },
337+ 1,
338+ );
339+ assert!(!json.contains("photo"), "got: {json}");
340+ }
341+
342+ #[test]
343+ fn snake_case_field_names_on_wire() {
344+ let p = Payload::Profile {
345+ display_name: Some("A".into()),
346+ bio: Some("B".into()),
347+ photo: None,
348+ };
349+ let json = serialize_payload(&p, 1);
350+ assert!(json.contains("\"display_name\""));
351+ let ack = serialize_payload(&Payload::DeliveryAck { ack_ids: vec![] }, 1);
352+ assert!(ack.contains("\"ack_ids\""));
353+ assert!(ack.contains("\"delivery.ack\""));
354+ }
355+}
dotnet-tools.jsondeleted−13
@@ -1,13 +0,0 @@
1−{
2− "version": 1,
3− "isRoot": true,
4− "tools": {
5− "fantomas": {
6− "version": "7.0.5",
7− "commands": [
8− "fantomas"
9− ],
10− "rollForward": false
11− }
12− }
13−}
install.sh+186−14
@@ -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)
@@ -34,9 +77,46 @@ detect_platform() {
3477 *) echo "error: unsupported architecture: $arch" >&2; exit 1 ;;
3578 esac
3679
80+ # CI only publishes server-macos-arm64; there is no Intel macOS asset, so bail
81+ # out here rather than 404ing on the download.
82+ if [ "$os_name" = "macos" ] && [ "$arch_name" != "arm64" ]; then
83+ echo "error: only arm64 (Apple silicon) macOS is supported; no release asset is built for Intel macOS" >&2
84+ exit 1
85+ fi
86+
3787 echo "${os_name}-${arch_name}"
3888}
3989
90+# Install Caddy with whichever package manager the host actually has. The
91+# upstream Debian repo recipe is apt-only; on RHEL-family hosts (which this
92+# script otherwise supports — see the firewalld branch below) `apt-get` does not
93+# exist and `set -e` would abort the whole install. Caddy ships an official COPR
94+# repo there instead.
95+install_caddy() {
96+ if command -v apt-get >/dev/null 2>&1; then
97+ apt-get update
98+ apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
99+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
100+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
101+ apt-get update
102+ apt-get install -y caddy
103+ elif command -v dnf >/dev/null 2>&1; then
104+ dnf install -y 'dnf-command(copr)'
105+ dnf copr enable -y @caddy/caddy
106+ dnf install -y caddy
107+ elif command -v yum >/dev/null 2>&1; then
108+ yum install -y yum-plugin-copr
109+ yum copr enable -y @caddy/caddy
110+ yum install -y caddy
111+ elif command -v brew >/dev/null 2>&1; then
112+ brew install caddy
113+ else
114+ echo "error: no supported package manager found (apt-get, dnf, yum, brew)" >&2
115+ echo "install Caddy manually (https://caddyserver.com/docs/install), then re-run" >&2
116+ exit 1
117+ fi
118+}
119+
40120PLATFORM=$(detect_platform)
41121ASSET="server-${PLATFORM}"
42122
@@ -44,22 +124,73 @@ echo "Detected platform: ${PLATFORM}"
44124echo "Downloading latest release..."
45125
46126DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ASSET}"
127+CHECKSUM_URL="${DOWNLOAD_URL}.sha256"
128+
129+# Download to a temp file next to the destination, then rename. Writing straight
130+# to $INSTALL_BIN fails with ETXTBSY while the service is running; rename(2) is
131+# atomic and happily replaces a busy binary (the running process keeps the old
132+# inode until it is restarted below).
133+TMP_BIN="${INSTALL_BIN}.new.$$"
134+TMP_SUM="${TMP_BIN}.sha256"
135+trap 'rm -f "$TMP_BIN" "$TMP_SUM"' EXIT INT TERM
47136
48−if command -v curl >/dev/null 2>&1; then
49− curl -fSL "$DOWNLOAD_URL" -o "$INSTALL_BIN"
50−elif command -v wget >/dev/null 2>&1; then
51− wget -q "$DOWNLOAD_URL" -O "$INSTALL_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+# N15: Verification is mandatory. A missing checksum, a missing sha256sum tool,
158+# or a mismatch are all fatal — the binary is refused.
159+# N15: Checksum verification is mandatory for all installs. A missing
160+# checksum file indicates either a pre-checksum release or a supply-chain
161+# attack that removed the checksum. We refuse to install without verification
162+# rather than silently proceeding.
163+if fetch_to "$CHECKSUM_URL" "$TMP_SUM" 2>/dev/null; then
164+ EXPECTED=$(cut -d' ' -f1 < "$TMP_SUM")
165+ ACTUAL=$(sha256_of "$TMP_BIN")
166+ if [ -z "$ACTUAL" ]; then
167+ echo "error: no sha256sum/shasum available; cannot verify binary integrity" >&2
168+ exit 1
169+ elif [ -z "$EXPECTED" ]; then
170+ echo "error: checksum file is empty; refusing to install unverified binary" >&2
171+ exit 1
172+ elif [ "$EXPECTED" != "$ACTUAL" ]; then
173+ echo "error: checksum verification FAILED for ${ASSET}" >&2
174+ echo " expected: ${EXPECTED}" >&2
175+ echo " actual: ${ACTUAL}" >&2
176+ echo "refusing to install a binary that does not match its published checksum" >&2
177+ exit 1
178+ else
179+ echo "Checksum verified (${ACTUAL})"
180+ fi
52181else
53− echo "error: curl or wget required" >&2
182+ echo "error: no published checksum at ${CHECKSUM_URL}; refusing to install unverified binary" >&2
183+ echo " (releases predating checksum publication have none — install from a recent release)" >&2
54184 exit 1
55185fi
56186
57−chmod 755 "$INSTALL_BIN"
187+chmod 755 "$TMP_BIN"
188+mv -f "$TMP_BIN" "$INSTALL_BIN"
58189echo "Installed binary to ${INSTALL_BIN}"
59190
60191case "$PLATFORM" in
61192 linux-*)
62− cat > "$UNIT_DEST" <<'EOF'
193+ cat > "$UNIT_DEST" <<EOF
63194[Unit]
64195Description=Skrepka relay server
65196After=network-online.target
@@ -67,7 +198,31 @@ Wants=network-online.target
67198
68199[Service]
69200Type=simple
70−ExecStart=/usr/local/bin/skrepka-server
201+# --bindHost keeps the relay on the loopback interface. Caddy terminates TLS and
202+# reverse-proxies to localhost:8080, so nothing needs to reach the backend
203+# directly — and it must not. The server trusts X-Forwarded-For for rate-limit
204+# and session-IP bucketing (--trustForwardedFor defaults to True, which is correct
205+# *behind* Caddy), so a backend reachable from the internet lets any client set
206+# that header itself and mint a fresh rate-limit bucket per request, defeating
207+# every per-IP limit at once.
208+# --serverHost is the federation identity AND the hostname clients bind their
209+# auth signature to ("skrepka-auth-v1:<host>:<challenge>"). It defaults to
210+# "localhost", which no client ever dials, so /auth/verify would reject every
211+# signature. It must be the public name clients use to reach this relay.
212+# --http-max-body-bytes must exceed maxBlobLen (40 MiB hex) plus the JSON
213+# envelope, otherwise the runtime's default 16 MiB body cap rejects a
214+# max-size blob with 413 before the handler's BlobHex check is ever reached.
215+#
216+# WARNING: as of Knot 2026.6.26.1947 the runtime ACCEPTS but SILENTLY IGNORES
217+# --<name>=<value> constant overrides; only --debug, --help and
218+# --http-max-body-bytes are honoured at startup (constant overrides currently work
219+# only at `knot build` time). So --bindHost and --serverHost below are inert on
220+# today's released binary: it listens on 0.0.0.0:8080 regardless. They are kept
221+# because they are the documented interface and become effective the moment the
222+# runtime honours them. Until then the loopback restriction is enforced at the
223+# firewall instead (see the port-8080 rules below) — do not remove those on the
224+# assumption that --bindHost is doing the work.
225+ExecStart=/usr/local/bin/skrepka-server --bindHost=127.0.0.1 --serverHost=${DOMAIN} --http-max-body-bytes=42M
71226Restart=on-failure
72227RestartSec=5
73228
@@ -86,11 +241,7 @@ EOF
86241
87242 if ! command -v caddy >/dev/null 2>&1; then
88243 echo "Installing Caddy..."
89− apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
90− curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
91− curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
92− apt-get update
93− apt-get install -y caddy
244+ install_caddy
94245 fi
95246
96247 mkdir -p /etc/caddy
@@ -100,15 +251,36 @@ ${DOMAIN} {
100251}
101252CADDYEOF
102253
254+ # Ports 80/443 are the public surface (Caddy). Port 8080 — the relay backend —
255+ # must never be reachable from off-box: it speaks plain HTTP and trusts
256+ # X-Forwarded-For, so a direct caller can forge that header and bypass every
257+ # per-IP rate limit. This is normally the job of --bindHost=127.0.0.1 in the
258+ # unit above, but the current runtime ignores constant overrides (see the
259+ # WARNING there), so the binary really is listening on 0.0.0.0:8080 and the
260+ # firewall is what actually closes it. Loopback is unaffected by these rules —
261+ # ufw always permits the `lo` interface and firewalld puts it in the trusted
262+ # zone — so Caddy -> localhost:8080 keeps working.
103263 if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "active"; then
104− echo "Opening ports 80, 443 (ufw)..."
264+ echo "Opening ports 80, 443 and blocking 8080 (ufw)..."
105265 ufw allow 80/tcp
106266 ufw allow 443/tcp
267+ ufw deny 8080/tcp
107268 elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
108269 echo "Opening ports 80, 443 (firewalld)..."
109270 firewall-cmd --add-port=80/tcp --permanent
110271 firewall-cmd --add-port=443/tcp --permanent
272+ # firewalld denies anything not explicitly opened, so 8080 is already closed;
273+ # just make sure a previous install did not leave it open.
274+ firewall-cmd --remove-port=8080/tcp --permanent 2>/dev/null || true
111275 firewall-cmd --reload
276+ else
277+ echo "" >&2
278+ echo "WARNING: no active ufw/firewalld found — port 8080 was NOT firewalled." >&2
279+ echo " The relay backend listens on 0.0.0.0:8080 in plain HTTP and trusts" >&2
280+ echo " X-Forwarded-For, so anyone who can reach it directly can spoof that" >&2
281+ echo " header and bypass all per-IP rate limiting." >&2
282+ echo " Block inbound 8080 at your host or cloud firewall / security group." >&2
283+ echo "" >&2
112284 fi
113285
114286 systemctl daemon-reload
server.knot+1128−250
@@ -28,8 +28,28 @@
2828-- and other private/reserved names), so two default-built servers cannot
2929-- federate with each other. The default is only suitable for a single-node,
3030-- non-federated deployment.
31−serverHost : ServerName
31+--
32+-- Typed `Text`, not `ServerName`: a refined type cannot be given a bare literal
33+-- default (the compiler requires an explicit `refine`), and this constant is
34+-- overridable at startup. `localServerName` below is where the `ServerName`
35+-- predicate is actually checked — an operator who overrides this with a name
36+-- that fails `isValidServerName` (too long, not lowercase, or trailing-dotted)
37+-- gets federation egress disabled and a loud startup warning, rather than an
38+-- invalid name on the wire.
39+serverHost : Text
3240serverHost = "localhost"
41+
42+-- `serverHost` refined to a `ServerName`, or `Nothing` when it fails
43+-- `isValidServerName`. This is the only place the predicate is checked for our
44+-- own hostname; peer-supplied names are validated by the runtime at the
45+-- `/federation/gossip` route boundary (`fromServer: ServerName`). Federation
46+-- egress (`sendBatchedGossip`) needs a `ServerName` to put in the gossip body,
47+-- so a misconfigured host simply stops gossiping — see `main`, which warns at
48+-- startup.
49+localServerName : Maybe ServerName
50+localServerName = case refine serverHost of
51+ Ok {value: sn} -> Just {value: sn}
52+ Err _ -> Nothing {}
3353-- Network interface the HTTP listener binds to. "0.0.0.0" accepts connections
3454-- on every IPv4 interface (the typical default behind a reverse proxy);
3555-- "127.0.0.1" restricts the listener to localhost-only; "::" / "::1" do the
@@ -52,33 +72,140 @@ messageMaxAge = 2592000000
5272-- Max length of a hex-encoded message body / opaque blob (40 MiB hex ≈ 20 MiB bytes).
5373maxBlobLen : Int
5474maxBlobLen = 41943040
75+-- Min length of a hex-encoded blob: 346 chars = 173 bytes. A well-formed blob is
76+-- `version (1) || ephemeral_x25519_pubkey (32) || nonce (24) || ciphertext`, and the
77+-- ciphertext seals at minimum `sender_pubkey (32) || signature (64) || compressed_len (4)`
78+-- plus the 16-byte Poly1305 tag — so 1 + 32 + 24 + 32 + 64 + 4 + 16 = 173 bytes is the
79+-- floor for a blob any client could possibly decrypt (PROTOCOL.md §3). Anything shorter
80+-- is 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 = 346
5584-- Cap on items in a batched send request.
5685maxBatchSize : Int
5786maxBatchSize = 100
5887-- Cap on gossip events accepted or emitted in a single federation exchange.
5988maxGossipEvents : Int
6089maxGossipEvents = 100
90+-- N13: Cap on newly-online keys per gossip batch that trigger forward sweeps.
91+-- Without this, a single gossip request with 100 `online` events triggers 100
92+-- mailbox scans (O(100 × |messages|)). Capping at 10 limits the amplification
93+-- while still allowing reasonable multi-key presence announcements. Remaining
94+-- newly-online keys are still written to `*presence` — only the forward sweep
95+-- is deferred until the next presence re-announce or a poll triggers
96+-- `forwardQueuedToServer` naturally.
97+maxNewlyOnlineForwards : Int
98+maxNewlyOnlineForwards = 10
6199-- Cap on events returned by one long-poll response.
62100maxPollEvents : Int
63101maxPollEvents = 50
64102-- Per-peer ceiling on outstanding forward attempts.
65103maxForwardsPerServer : Int
66104maxForwardsPerServer = 2000
105+-- S9: Cap on distinct remote servers a single message is immediately forwarded
106+-- to at send time. Separate from `maxForwardsPerServer` (which bounds outstanding
107+-- forwards *per peer*), this bounds the fan-out breadth. A recipient is unlikely
108+-- to be visible on more than 100 servers; the previous reuse of
109+-- `maxForwardsPerServer` (2000) for this purpose was an overload that could
110+-- spawn up to 2000 immediate HTTP forwards per message.
111+maxRemoteServersPerMessage : Int
112+maxRemoteServersPerMessage = 100
113+-- Global ceiling on the `*forwards` outbox. `maxForwardsPerServer` bounds any one
114+-- peer, but a recipient gossiped as present on many servers fans one blob out to
115+-- all of them, so the total is bounded only by (peers x per-peer cap). This caps
116+-- the whole outbox.
117+--
118+-- Evicts the *highest*-retry entries first: those are the ones closest to
119+-- `maxForwardRetries` (about to be abandoned anyway), so pressure is shed onto
120+-- failing peers rather than healthy ones. Freshly-staged rows carry `retries: 0`
121+-- and so always survive their own capping.
122+--
123+-- Dropping a forward is not message loss: the `*messages` row it points at stays
124+-- put, so the recipient still gets the blob by polling here, and a later presence
125+-- announce re-stages the forward via `forwardQueuedToServer`.
126+maxForwardsTotal : Int
127+maxForwardsTotal = 50000
67128-- Max accepted length of a server hostname.
68129maxServerNameLen : Int
69130maxServerNameLen = 128
70131-- Ceiling on outstanding auth challenges held in memory.
71132maxChallenges : Int
72133maxChallenges = 10000
73−-- Ceiling on concurrent active sessions.
134+-- Ceiling on concurrent active sessions. Enforced by `capList` in
135+-- `handleVerifyAuth`, which LRU-evicts the soonest-to-expire sessions once the
136+-- pool is full.
137+--
138+-- LIMITATION — global LRU eviction is Sybil-floodable: an attacker with enough
139+-- IPs can evict live users' sessions. Bounded by `maxSessionsPerKey` and per-IP
140+-- `authRateLimit`; a per-IP session cap was rejected (collapses without XFF,
141+-- punishes carrier-NAT). A real fix prices session creation, not IPs. See PROTOCOL.md §10.
74142maxSessions : Int
75143maxSessions = 10000
76144-- Per-pubkey ceiling on outstanding challenges (prevents one key from exhausting the pool).
77145maxChallengesPerKey : Int
78146maxChallengesPerKey = 5
79147-- Per-pubkey ceiling on concurrent sessions.
148+--
149+-- LIMITATION — concurrent sessions for the same keypair are not supported: the
150+-- mailbox is keyed by pubkey, so two devices sharing a key poll one mailbox and
151+-- each ack destroys messages the other hasn't read. The model is one keypair per
152+-- device (PROTOCOL.md §2). This cap only bounds pool exhaustion, not key-sharing.
153+-- A fix needs per-session delivery cursors — out of scope for v0.1.
80154maxSessionsPerKey : Int
81155maxSessionsPerKey = 10
156+-- Ceiling on gossip-learned (remote) presence rows.
157+--
158+-- `/federation/gossip` is unauthenticated and pubkeys are free to invent, so
159+-- without a cap one peer can grow `*presence` without bound. `handleRecvGossip`
160+-- LRU-evicts (`capList`) down to this ceiling.
161+--
162+-- Local rows are exempt: they're bounded by `maxSessions` and a shared ceiling
163+-- would let gossip floods evict our own online users (local TTL 60 min < gossip
164+-- TTL 90 min, so `capList` drops local rows first). See PROTOCOL.md §10.
165+maxPresence : Int
166+maxPresence = 100000
167+-- Per-peer-server ceiling on gossip-learned presence rows, so a single peer cannot
168+-- consume the whole `maxPresence` pool and evict every other peer's routing
169+-- information. Filling the global pool then requires many distinct hostnames that
170+-- each pass `isBadServerName`.
171+maxPresencePerServer : Int
172+maxPresencePerServer = 5000
173+-- Per-recipient ceiling on stored mailbox rows. Without a cap a single
174+-- authenticated sender can queue unbounded data for one recipient
175+-- (`maxBatchSize` 100 messages/request x `sendRateLimit` 60 requests/min =
176+-- 6000/min, each blob up to 20 MiB) and only the 30-day `messageMaxAge` TTL
177+-- ever reclaims it. Past the cap `appendMessage` drops the oldest rows (lowest
178+-- `receivedAt`) — the same rows the TTL would eventually drop, just sooner.
179+--
180+-- The recipient's client bounds what it *processes* per poll (MAX_POLL_TOTAL_BYTES
181+-- in core/src/app.rs), but nothing bounded what the server *stores* before this.
182+--
183+-- Dropping a row orphans any `*forwards` entry for it; `pruneExpired` collects
184+-- those (it drops forwards whose message no longer exists). Its `deliveredTo`
185+-- set is lost with it, which is harmless — the blob is gone, so there is
186+-- nothing left to re-forward.
187+--
188+-- LIMITATION — evicting on overflow means a flood can now *destroy* mail, not
189+-- just pile it up: an attacker who fills a recipient's mailbox pushes that
190+-- recipient's genuine undelivered messages out of the window. Pubkeys are free
191+-- to mint, so "authenticated sender" is no barrier. This is a trade, not a fix
192+-- — the alternative (reject once full) hands the same attacker a permanent
193+-- block on *all* new mail to that recipient, which is strictly worse: here at
194+-- least the newest messages always land. It only bites a recipient who is
195+-- offline long enough to accumulate `maxMessagesPerRecipient`, since polling
196+-- drains the mailbox (`ackPolledMessages`). A real fix prices sending — see
197+-- the same argument for sessions on `maxSessions`, and PROTOCOL.md §10.
198+maxMessagesPerRecipient : Int
199+maxMessagesPerRecipient = 10000
200+-- Global ceiling on total stored messages across all recipients (S6). Every
201+-- other state ref has a global cap (*sessions, *challenges, *presence,
202+-- *forwards); *messages had only a per-recipient cap, so total storage was
203+-- bounded only by (distinct recipient keys x maxMessagesPerRecipient), and an
204+-- attacker minting fresh invalid pubkeys could fill the disk (bounded only by
205+-- rate limits and the 30-day TTL). On overflow this evicts the globally-oldest
206+-- rows, symmetric with the other refs. See PROTOCOL.md §10.
207+maxMessagesTotal : Int
208+maxMessagesTotal = 1000000
82209-- Validity window for an auth challenge before it must be re-issued (1 min).
83210challengeExpiry : Int<Ms>
84211challengeExpiry = 60000
@@ -116,16 +243,19 @@ pollTimeoutMs = 25000
116243-- share this window; only the per-window request budget varies per endpoint.
117244rateLimitWindow : Int<Ms>
118245rateLimitWindow = 60000
119−-- Per-pubkey budget for /auth/challenge and /auth/verify per `rateLimitWindow`.
120−-- The normal handshake is one challenge + one verify, so a healthy flow uses 2.
246+-- Per-client-IP budget for /auth/challenge and /auth/verify per `rateLimitWindow`.
247+-- A healthy handshake is one challenge + one verify, so the budget leaves ample
248+-- room for many clients behind one NAT.
121249authRateLimit : Int
122−authRateLimit = 10
123−-- Per-session (bearer token) budget for /poll per `rateLimitWindow`. Clients
124−-- reconnect after `pollTimeoutMs`, so steady state is ~3/min.
250+authRateLimit = 60
251+-- Per-client-IP budget for /poll per `rateLimitWindow`. Clients reconnect after
252+-- `pollTimeoutMs`, so steady state is ~3/min per client; the budget leaves room
253+-- for several clients behind one NAT.
125254pollRateLimit : Int
126255pollRateLimit = 60
127−-- Per-session budget for /messages per `rateLimitWindow`. Each call may carry
128−-- up to `maxBatchSize` ciphertexts, so the effective message ceiling is higher.
256+-- Per-client-IP budget for /messages per `rateLimitWindow`. Each call may
257+-- carry up to `maxBatchSize` ciphertexts, so the effective message ceiling
258+-- is higher.
129259sendRateLimit : Int
130260sendRateLimit = 60
131261-- Per-peer-IP budget for /federation/gossip per `rateLimitWindow`.
@@ -144,9 +274,43 @@ forwardRateLimit = 600
144274trustForwardedFor : Bool
145275trustForwardedFor = True {}
146276
147−-- Master switch for federation; when False the server runs in single-node mode.
277+-- Master switch for federation; when False the server runs in single-node mode:
278+-- no gossip is emitted, no forwards are attempted, and both /federation/*
279+-- endpoints answer 403.
280+--
281+-- Defaults to False. Federation is unauthenticated by design (PROTOCOL.md §10) —
282+-- any host that can reach us may announce presence or inject a blob — so joining
283+-- the mesh is a deliberate operator decision, not something a default hands you.
284+-- Opt in with `--federationEnabled=True`.
148285federationEnabled : Bool
149−federationEnabled = True {}
286+federationEnabled = False {}
287+
288+-- Federation bootstrap seeds: comma-separated peer hostnames this server gossips
289+-- to unconditionally, e.g. `--seedPeers=relay1.example.com,relay2.example.com`.
290+--
291+-- Without seeds the mesh cannot form. Peers are otherwise *only* ever learned
292+-- from inbound gossip (a `*presence` row remembers the `server` that announced a
293+-- key), so two fresh relays that have never heard of each other both start with
294+-- an empty gossip target set and stay that way forever — neither one speaks
295+-- first. Seeding one side is enough to bootstrap a pair: our gossip carries
296+-- `fromServer`, so the seed learns of *us* from the first exchange and gossips
297+-- back from then on.
298+--
299+-- Typed `Text`, not `[Text]`: only scalar top-level constants are startup-
300+-- overridable (see the header note), so the list travels as one comma-separated
301+-- scalar and `seedPeerList` splits it.
302+seedPeers : Text
303+seedPeers = ""
304+
305+-- `seedPeers` split on commas, trimmed, with blanks and our own hostname dropped.
306+-- Deliberately *not* filtered through `isBadServerName` here: every federation
307+-- egress site applies the deny-list itself, so a denylisted seed is refused at
308+-- exactly the same choke point as a denylisted gossip-learned peer.
309+-- Filtering `serverHost` out at parse time lets `gossipTargets` drop its
310+-- `isLocalServer` guard (the only other source, `collectPeers`, already filters it).
311+seedPeerList : [Text]
312+seedPeerList = filter (\s -> s != "" && not isLocalServer s) (splitOnComma seedPeers)
313+
150314federationUrl : Text -> Text
151315federationUrl = \server -> federationScheme ++ server
152316
@@ -162,42 +326,29 @@ federationUrl = \server -> federationScheme ++ server
162326-- polling pubkey. The server cannot read the blob.
163327type PollEvent = {encryptedBlob: BlobHex}
164328
165−-- One presence change exchanged between federated servers. Per-variant
166−-- payloads keep room for future event kinds that aren't tied to a pubkey.
167−-- `GossipUnknown` is the parser's fallback for forward-compatibility.
168−data GossipEventType
169− = GossipOnline {pubkey: PubkeyHex}
170− | GossipOffline {pubkey: PubkeyHex}
171− | GossipUnknown {}
172−
173−impl ToJSON GossipEventType where
174− toJson et = case et of
175− GossipOnline {pubkey} -> toJson {eventType: "online", pubkey}
176− GossipOffline {pubkey} -> toJson {eventType: "offline", pubkey}
177− GossipUnknown {} -> toJson {eventType: "unknown"}
178−
179−impl FromJSON GossipEventType where
180− parseJson t = (\raw -> if raw.eventType == "online" then GossipOnline {raw.pubkey} else if raw.eventType == "offline" then GossipOffline {raw.pubkey} else GossipUnknown {}) (parseJson t : {eventType: Text, pubkey: PubkeyHex})
181−
182−-- /messages is all-or-nothing per call: either every message in the batch is
183−-- enqueued (delivered, federated, or buffered for federation), or none are
184−-- and the call returns a non-2xx HTTP status. The per-message rejection code
185−-- "self_send" surfaces in the HTTP error body. Per-session rate limiting is
186−-- enforced by the route's `rateLimit` clause, which short-circuits with 429
187−-- before the handler runs.
188−-- The sender's UI treats every success the same and learns "they got it"
189−-- only via the recipient's E2E ack.
190−
191−-- True when `s` is non-empty lowercase hex of even length, with `maxLen` ceiling.
192−isValidBoundedHex : Int -> Text -> Bool
193−isValidBoundedHex = \maxLen s -> length s <= maxLen && isValidHex s
329+-- One presence change exchanged between federated servers. A flat wire record
330+-- (`{"eventType": "online"|"offline", "pubkey": "<hex>"}`) rather than an ADT:
331+-- the built-in record JSON round-trips it directly (a `Just` pubkey encodes as
332+-- the bare hex string, `Nothing` as `null`/an absent field). `eventType` is
333+-- free `Text`, so an unrecognized future event kind decodes cleanly instead of
334+-- failing the whole batch, and `pubkey` is optional so a future pubkey-less
335+-- kind still parses. `handleRecvGossip` ignores any event whose `eventType` it
336+-- doesn't recognize or that carries no pubkey — that is the forward-compat
337+-- fallback (the old `GossipUnknown` variant).
338+type GossipEvent = {eventType: Text, pubkey: Maybe Text}
339+
194340-- True when `s` is lowercase hex of exactly `exactLen` characters (even by definition).
195341isValidExactHex : Int -> Text -> Bool
196342isValidExactHex = \exactLen s -> length s == exactLen && isValidHex s
343+-- True when `s` is lowercase hex within [`minBlobLen`, `maxBlobLen`]. The floor
344+-- rejects blobs too short to be a real envelope; see `minBlobLen`.
345+isValidBlobHex : Text -> Bool
346+isValidBlobHex = \s -> length s >= minBlobLen && length s <= maxBlobLen && isValidHex s
197347-- Hex-encoded Ed25519 public key (32 bytes → 64 chars exact).
198348type PubkeyHex = Text where isValidExactHex 64
199−-- Hex-encoded ciphertext or opaque blob; capped at `maxBlobLen` (~20 MiB).
200−type BlobHex = Text where isValidBoundedHex maxBlobLen
349+-- Hex-encoded ciphertext or opaque blob; bounded to [`minBlobLen`, `maxBlobLen`]
350+-- (168 bytes ≈ 20 MiB).
351+type BlobHex = Text where isValidBlobHex
201352-- Hex-encoded Ed25519 signature (64 bytes → 128 chars exact).
202353type SignatureHex = Text where isValidExactHex 128
203354-- Normalized hostname (lowercased, no trailing dot, length-bounded). The SSRF
@@ -205,6 +356,19 @@ type SignatureHex = Text where isValidExactHex 128
205356-- (`handleRecvGossip`); keeping it out of the type predicate lets the default
206357-- `serverHost = "localhost"` flow through presence rows in a non-federated
207358-- single-node deployment.
359+--
360+-- WHY SERVER NAMES STAY `Text` IN STATE (the canonical note; other sites point
361+-- here). Every server name held in `*presence`, `*forwards`, `*forwardFailures`
362+-- and `Message.deliveredTo` is plain `Text`, never `ServerName`. They all
363+-- originate in `serverHost` — a startup-overridable scalar, so a refined type
364+-- cannot be given a bare literal default and the predicate can only be checked at
365+-- runtime (`localServerName`). `ServerName` is therefore enforced exactly where it
366+-- can be: at the `/federation/gossip` route boundary, where the name is
367+-- peer-supplied (`fromServer: ServerName`). Handlers that receive the refined type
368+-- widen it once, up front (`let server = (serverName : Text)`), because Knot does
369+-- not implicitly coerce between a refined type and its base inside a comparison —
370+-- `f.toServer == server` would otherwise fail to typecheck. Widening is always
371+-- sound (the predicate only adds constraints); narrowing is what needs `refine`.
208372isValidServerName : Text -> Bool
209373isValidServerName = \s -> length s <= maxServerNameLen && s == toLower (stripTrailingDot s)
210374type ServerName = Text where isValidServerName
@@ -222,15 +386,20 @@ type Session = {token: Text, pubkey: PubkeyHex, expiresAt: Timestamp, ip: Text}
222386-- `encryptedBlob` (the server cannot read it). `deliveredTo` records peers
223387-- that have already accepted a federated forward of this blob, so a later
224388-- presence re-announce of `toKey` doesn't trigger a duplicate delivery.
225−type Message = {toKey: PubkeyHex, encryptedBlob: BlobHex, receivedAt: Timestamp, deliveredTo: [ServerName]}
389+-- (Single-field record rows, not bare names: the Knot runtime cannot persist
390+-- a nested relation of scalars.)
391+type Message = {toKey: PubkeyHex, encryptedBlob: BlobHex, receivedAt: Timestamp, deliveredTo: [{server: Text}]}
226392-- Last-known location of a pubkey, either local (`server == serverHost`) or
227393-- learned from a peer via gossip. Multiple entries per pubkey are allowed.
228−type PresenceEntry = {pubkey: PubkeyHex, server: ServerName, expiresAt: Timestamp}
394+type PresenceEntry = {pubkey: PubkeyHex, server: Text, expiresAt: Timestamp}
229395-- A message queued for cross-server delivery to `toServer`. `retries` is
230396-- incremented by `retryPendingForwards` until `maxForwardRetries` is hit.
231397-- Identity is derived from `encryptedBlob` (see `Message`).
232−type ForwardEntry = {toServer: ServerName, toKey: PubkeyHex, encryptedBlob: BlobHex, retries: Int}
398+type ForwardEntry = {toServer: Text, toKey: PubkeyHex, encryptedBlob: BlobHex, retries: Int}
233399-- Running failure counter for a peer; keyed by `server`. Drives `shouldRetryServer`.
400+-- `failedAt` is the start time of the last recorded attempt; `failures: 0` is
401+-- the post-success tombstone (no backoff, kept so the timestamp can outrank
402+-- slower concurrent failures).
234403type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int}
235404
236405-- ============================================
@@ -253,33 +422,49 @@ type ForwardFailure = {server: Text, failedAt: Timestamp, failures: Int}
253422-- Mailbox: ciphertexts addressed to local recipients, awaiting poll or federation forward.
254423*messages : [Message]
255424-- Last-known location of each pubkey (local sessions and gossiped remote presence).
425+-- Remote (gossiped) rows are capped by `maxPresence` / `maxPresencePerServer` in
426+-- `handleRecvGossip`; local rows are exempt and bounded by `maxSessions` instead.
256427*presence : [PresenceEntry]
257428-- Cross-server outbox: messages queued for delivery to a peer, with retry counts.
258429*forwards : [ForwardEntry]
259430-- Per-peer failure counters; drives `shouldRetryServer`.
260431*forwardFailures : [ForwardFailure]
261432
262−-- Strictly-monotonic sequence used as `Message.receivedAt`; guarantees poll-cursor ordering.
263−*seqCounter : Timestamp
433+-- Strictly-monotonic sequence used as `Message.receivedAt`; guarantees
434+-- poll-cursor ordering. Held as a 0-or-1-row wrapped relation, not a bare
435+-- `Timestamp` scalar source: the runtime now panics when a scalar source's
436+-- backing table is empty (it treats a missing row as corruption rather than
437+-- defaulting to 0), which would 500 the very first /poll or /messages on a
438+-- fresh database. Read through `currentSeq` (0 default). The `{value: ...}`
439+-- record wrapper is required because a relation of bare scalars (`[Timestamp]`)
440+-- also can't be persisted — same reason `Message.deliveredTo` is wrapped.
441+*seqCounter : [{value: Timestamp}]
264442
265443-- ============================================
266444-- Rate limiting
267445-- ============================================
268−-- Per-endpoint token-bucket configuration. Auth endpoints key on the submitted
269−-- pubkey (so one keypair can't drain global capacity); authenticated client
270−-- endpoints key on the bearer token (per-session); federation endpoints key on
271−-- the client IP (last X-Forwarded-For hop behind a trusted proxy, else the
272−-- connection address). Each route has an independent bucket store, so endpoints
273−-- using the same key strategy don't share budget across routes. Buckets persist
274−-- in the runtime's `_knot_rate_limits` SQLite table; rejection responds 429
275−-- before the handler runs.
446+-- Per-endpoint token-bucket configuration. Every endpoint keys on the client IP
447+-- (last X-Forwarded-For hop behind a trusted proxy, else the connection
448+-- address). Nothing the caller can mint for free is a bucket key: the limiter
449+-- runs before the handler, so it cannot tell a valid session from an invented
450+-- one, and an attacker varying a self-chosen value would get a fresh bucket per
451+-- value. That rules out the bearer token (any string) and the submitted pubkey
452+-- (any keypair) alike. A pubkey key is abusable in both directions: an attacker
453+-- rotating keypairs draws a fresh budget with every rotation (a Sybil flood could
454+-- then push sessions through until `maxSessions` starts evicting live ones), and
455+-- flooding a *victim's* pubkey would exhaust that victim's own login budget.
456+-- Per-pubkey exhaustion of the challenge/session pools is bounded separately, by
457+-- `maxChallengesPerKey` and `maxSessionsPerKey` in the handlers. Each route has an
458+-- independent bucket store, so endpoints using the same key strategy don't share
459+-- budget across routes. Buckets persist in the runtime's `_knot_rate_limits`
460+-- SQLite table; rejection responds 429 before the handler runs.
276461
277462-- Suffix of `s` after its last comma, trimmed. X-Forwarded-For is a
278463-- comma-separated chain that each proxy appends to; only the final entry —
279464-- the one written by the nearest trusted proxy — is reliable for bucketing.
280465lastForwardedHop : Text -> Text
281466lastForwardedHop = \s -> if contains "," s
282−then lastForwardedHop (drop 1 s)
467+then lastForwardedHop (afterComma s)
283468else trim s
284469
285470-- The client address for rate-limit bucketing: the last X-Forwarded-For hop
@@ -287,18 +472,16 @@ else trim s
287472-- otherwise the TCP connection address.
288473pickClientIp : Maybe Text -> Text -> Text
289474pickClientIp = \h connIp -> case h of
290− Just {value: ips} -> if trustForwardedFor && lastForwardedHop ips != ""
291− then lastForwardedHop ips
475+ Just {value: ips} -> if trustForwardedFor
476+ then let hop = lastForwardedHop ips in if hop != "" then hop else connIp
292477 else connIp
293478 Nothing {} -> connIp
294479
295−byPubkey = \{pubkey} _ -> Just {value: pubkey}
296−byAuth = \{authorization} _ -> Just {value: authorization}
297480byClientIp = \_ ctx -> Just {value: pickClientIp (ctx.header "X-Forwarded-For") ctx.clientIp}
298481
299−authLimit = {key: byPubkey, limit: {requests: authRateLimit, window: rateLimitWindow}}
300−pollLimit = {key: byAuth, limit: {requests: pollRateLimit, window: rateLimitWindow}}
301−sendLimit = {key: byAuth, limit: {requests: sendRateLimit, window: rateLimitWindow}}
482+authLimit = {key: byClientIp, limit: {requests: authRateLimit, window: rateLimitWindow}}
483+pollLimit = {key: byClientIp, limit: {requests: pollRateLimit, window: rateLimitWindow}}
484+sendLimit = {key: byClientIp, limit: {requests: sendRateLimit, window: rateLimitWindow}}
302485gossipLimit = {key: byClientIp, limit: {requests: gossipRateLimit, window: rateLimitWindow}}
303486forwardLimit = {key: byClientIp, limit: {requests: forwardRateLimit, window: rateLimitWindow}}
304487
@@ -322,7 +505,7 @@ route ClientApi where
322505 POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, xForwardedFor: Maybe Text} -> {} rateLimit sendLimit = SendMessages
323506
324507route FederationApi where
325− POST {events: [GossipEventType], fromServer: ServerName} /federation/gossip -> {} rateLimit gossipLimit = RecvGossip
508+ POST {events: [GossipEvent], fromServer: ServerName} /federation/gossip -> {} rateLimit gossipLimit = RecvGossip
326509 POST {toKey: PubkeyHex, encryptedBlob: BlobHex} /federation/forward -> {} rateLimit forwardLimit = ForwardMessage
327510
328511route Api = AuthApi | ClientApi | FederationApi
@@ -347,24 +530,51 @@ capList = \items maxN -> drop (max 0 (count items - maxN)) (sortBy (\x -> x.expi
347530capPerKey : [{pubkey: PubkeyHex, expiresAt: Timestamp | r}] -> PubkeyHex -> Int -> [{pubkey: PubkeyHex, expiresAt: Timestamp | r}]
348531capPerKey = \items pubkey maxN -> union (filter (\x -> not (x.pubkey == pubkey)) items) (capList (filter (\x -> x.pubkey == pubkey) items) (maxN - 1))
349532
533+-- Cap entries matching `server` to maxN, leaving every other server's entries
534+-- untouched. Same shape as `capPerKey` on a different field, but caps to `maxN`
535+-- rather than `maxN - 1`: the sole caller (`handleRecvGossip`) applies this
536+-- *after* merging the peer's new rows in, not before adding one more. Keeps one
537+-- peer from crowding every other peer out of the `maxPresence` pool.
538+capPerServer : [{server: Text, expiresAt: Timestamp | r}] -> Text -> Int -> [{server: Text, expiresAt: Timestamp | r}]
539+capPerServer = \items server maxN -> union (filter (\x -> not (x.server == server)) items) (capList (filter (\x -> x.server == server) items) maxN)
540+
541+-- Cap the whole forwards outbox to `maxForwardsTotal`, evicting the highest-retry
542+-- entries first — see `maxForwardsTotal` for why those and not the oldest.
543+-- Deliberately not `capList`: that keys on `expiresAt`, and `ForwardEntry` carries
544+-- no timestamp (the same reason `retryPendingForwards` has to sort on the blob).
545+-- Applied by both writers that grow `*forwards` (`forwardQueuedToServer` and
546+-- `handleSendMessages`); the retry sweep only ever shrinks it.
547+--
548+-- N4: Sort by `(retries, encryptedBlob)` for determinism. Without the secondary
549+-- key, a `sortBy retries` alone is unstable when many entries share `retries: 0`,
550+-- and any of them could be dropped — including a fresh forward for a healthy
551+-- peer. The blob is a unique secondary key (the dedup invariant guarantees
552+-- `(toKey, encryptedBlob, toServer)` uniqueness), so the sort is total and
553+-- deterministic.
554+capForwards : [ForwardEntry] -> [ForwardEntry]
555+capForwards = \fwds -> take maxForwardsTotal (sortBy (\f -> {retries: f.retries, blob: f.encryptedBlob}) fwds)
556+
350557-- Random hex token: one random hex char per recursive call. `n` is the
351558-- desired length in characters (so n=48 yields 24 random bytes of entropy).
352559-- Session bearers, /auth challenges, and federation auth nonces all use n=48
353560-- (192 bits of entropy, well beyond birthday bound).
354−genToken : Int -> IO {random} Text
355−genToken = \n -> if n <= 0
356−then yield ""
561+-- Tail-recursive: builds the string left-to-right via an accumulator (`acc`),
562+-- appending one hex char per call. Each `acc ++ ...` copies the accumulator,
563+-- so the total work is O(n²) in the length — but n is 48 (192 bits), so this
564+-- is a fixed ~1 KB of copying and not worth a more complex builder.
565+genToken : Int -> Text -> IO {random} Text
566+genToken = \n acc -> if n <= 0
567+then yield acc
357568else do
358569 d <- randomInt 16
359− rest <- genToken (n - 1)
360− yield (take 1 (drop d "0123456789abcdef") ++ rest)
570+ genToken (n - 1) (acc ++ take 1 (drop d "0123456789abcdef"))
361571
362572-- True when `s` is non-empty, even-length, and only lowercase hex digits.
363573-- `chars` yields the *distinct* character set (relations dedupe), which is
364574-- fine for the membership check; the length checks must use `length`.
365575isValidHex : Text -> Bool
366−isValidHex = \s -> do
367− let n = length s
576+isValidHex = \s ->
577+ let n = length s in
368578 n > 0 && n / 2 * 2 == n && all (\c -> contains c "0123456789abcdef") (chars s)
369579
370580-- Recursively strip every trailing "." from `s` (DNS root form).
@@ -392,27 +602,182 @@ countChar = \c s -> if s == ""
392602then 0
393603else (if take 1 s == c then 1 else 0) + countChar c (drop 1 s)
394604
395−-- Block private/reserved/internal hostnames (SSRF defense). Skips obscure encoding
396−-- tricks (octal, hex, percent-encoding) but covers the high-value cases:
397−-- `localhost`, RFC1918 ranges (10/8, 192.168/16, 169.254/16), the IPv4 zero
398−-- net, bare IPv6 literals (any string with more than one `:` — covers loopback
399−-- `::`/`::1`, link-local `fe80::/10`, ULA `fc00::/7`, globals like `2001:db8::1`),
400−-- bracketed IPv6 literals, and the `.local` / `.internal` / `.arpa` / `.onion`
401−-- suffixes. Called at federation egress (so we never `fetch` a peer pubkey from
402−-- loopback) and on inbound requests (so a peer can't claim a bogus `fromServer`).
403−-- A legitimate `host:port` server name has exactly one `:`, so the multi-colon
404−-- check leaves that case intact.
605+-- The host portion of `sn` — everything before the first `:`. With the
606+-- one-colon ceiling enforced in `isBadServerName`, this strips an optional
607+-- `:port` so `isNumericHost` sees just the host.
608+beforeColon : Text -> Text
609+beforeColon = \s -> if s == "" || take 1 s == ":"
610+then ""
611+else take 1 s ++ beforeColon (drop 1 s)
612+
613+-- `s` up to and including its first `]` (the whole string if there is none).
614+throughBracket : Text -> Text
615+throughBracket = \s -> if s == ""
616+then ""
617+else if take 1 s == "]"
618+then "]"
619+else take 1 s ++ throughBracket (drop 1 s)
620+
621+-- The bare hostname the auth signature is bound to (PROTOCOL.md §6): `serverHost`
622+-- with an optional `:port` stripped.
623+--
624+-- Deliberately *not* `beforeColon`. A bracketed IPv6 literal is made of colons,
625+-- so cutting at the first one turns `[::1]:8080` into `[`. The client derives
626+-- this side of the contract with a real URL parser (`server_host`, core/src/app.rs)
627+-- and signs over `[::1]`, so truncating here would reject every signature an
628+-- IPv6-hosted relay is ever sent. For a DNS name or an IPv4 literal the two
629+-- functions agree character for character.
630+authHost : Text -> Text
631+authHost = \s -> if take 1 s == "["
632+then throughBracket s
633+else beforeColon s
634+
635+-- True when the host is a bare integer (no dots) — a decimal or octal "dword" IP
636+-- like `2130706433` or `017700000001` (both = 127.0.0.1). These carry no dotted
637+-- octets, so the octet checks below never match them, yet they resolve to the same
638+-- internal addresses. Takes the already-port-stripped host (`beforeColon sn`).
639+isNumericHost : Text -> Bool
640+isNumericHost = \host -> host != "" && all (\c -> contains c "0123456789") (chars host)
641+
642+-- Decimal value of a single hex/ascii digit character.
643+digitValue : Text -> Int
644+digitValue = \c -> if c == "0" then 0 else if c == "1" then 1 else if c == "2" then 2 else if c == "3" then 3 else if c == "4" then 4 else if c == "5" then 5 else if c == "6" then 6 else if c == "7" then 7 else if c == "8" then 8 else 9
645+
646+-- Value of an all-digits string (e.g. "172" -> 172); -1 when empty or
647+-- non-numeric. `chars` is fine for the membership guard (set semantics don't
648+-- matter there); the actual parse walks the string in order via take/drop.
649+--
650+-- The accumulator saturates at 2^32 so an overlong all-digits octet cannot
651+-- overflow i64 and panic the handler. `isBadServerName` reaches this via
652+-- `isReservedIPv4` on a peer-supplied name (`/federation/gossip`'s `fromServer`
653+-- only passes a length/lowercase check at the route boundary), so a 19+-digit
654+-- octet like "9999999999999999999.0.0.1" would otherwise abort the request with
655+-- an unhandled `integer overflow in ... * 10`. A real IPv4 octet is 0-255 and
656+-- every `isReservedIPv4` comparison is against a constant <= 255, so saturating
657+-- above that range preserves the result for valid octets while an oversized
658+-- first octet still reads as `>= 224` (reserved → fail-closed). The `* 10`
659+-- stays safe because `acc` is capped at 2^32 before each step.
660+decimalValue : Text -> Int
661+decimalValue = \s -> if s == "" || not all (\c -> contains c "0123456789") (chars s)
662+then -1
663+else decimalValueGo 0 s
664+decimalValueGo : Int -> Text -> Int
665+decimalValueGo = \acc s -> if s == ""
666+then acc
667+else decimalValueGo (min 4294967296 (acc * 10 + digitValue (take 1 s))) (drop 1 s)
668+
669+-- Substring of `s` before / after its first ",". Same shape as `beforeDot` /
670+-- `afterDot`, on a different separator.
671+beforeComma : Text -> Text
672+beforeComma = \s -> if s == "" || take 1 s == ","
673+then ""
674+else take 1 s ++ beforeComma (drop 1 s)
675+afterComma : Text -> Text
676+afterComma = \s -> if s == ""
677+then ""
678+else if take 1 s == ","
679+then drop 1 s
680+else afterComma (drop 1 s)
681+
682+-- Split `s` on commas, trimming each field. The stdlib has no `split`, so this
683+-- is the usual beforeX/afterX recursion. Empty fields survive the split (",,a"
684+-- yields "" twice — relations dedupe, so once) and are dropped by the caller;
685+-- see `seedPeerList`.
686+--
687+-- The `beforeComma`/`afterComma`/`beforeDot`/`afterDot`/`beforeColon` family
688+-- looks ripe to fold into one generic `beforeChar sep s` / `afterChar sep s`.
689+-- Do NOT: routing *this* function's tail recursion through a partially-applied
690+-- `afterChar ","` hangs the Knot compiler indefinitely (confirmed 2026-07-11 —
691+-- a 9-line repro of `splitOnComma = ... splitOnComma (afterChar "," s)` never
692+-- finishes compiling, while the bespoke single-argument `afterComma` compiles
693+-- in ~1 s). The per-separator helpers stay bespoke on purpose.
694+splitOnComma : Text -> [Text]
695+splitOnComma = \s -> if s == ""
696+then ([] : [Text])
697+else union [trim (beforeComma s)] (splitOnComma (afterComma s))
698+
699+-- Substring of `s` before / after its first ".". `afterDot` is empty when
700+-- there is no dot. Used to pull the first two octets of a dotted IPv4 literal.
701+beforeDot : Text -> Text
702+beforeDot = \s -> if s == "" || take 1 s == "."
703+then ""
704+else take 1 s ++ beforeDot (drop 1 s)
705+afterDot : Text -> Text
706+afterDot = \s -> if s == ""
707+then ""
708+else if take 1 s == "."
709+then drop 1 s
710+else afterDot (drop 1 s)
711+
712+-- True when `host` is composed solely of digits and dots, i.e. a bare IPv4
713+-- literal rather than a hostname. Gates the numeric range checks below so a
714+-- legitimately-named peer like "224.example.com" is not misread as multicast.
715+isDottedNumeric : Text -> Bool
716+isDottedNumeric = \host -> host != "" && contains "." host && all (\c -> contains c "0123456789.") (chars host)
717+
718+-- Reserved/non-routable IPv4 ranges the dotted-octet prefix list misses:
719+-- CGNAT 100.64/10, benchmarking 198.18/15, and multicast + reserved/broadcast
720+-- (224/4 and 240/4, i.e. any first octet >= 224). Applied only to bare IPv4
721+-- literals (`isDottedNumeric`) so hostnames are never caught. Shorthand forms
722+-- like "100.64" still match — only the first two octets are inspected.
723+isReservedIPv4 : Text -> Bool
724+isReservedIPv4 = \host ->
725+ let o1 = decimalValue (beforeDot host) in
726+ let o2 = decimalValue (beforeDot (afterDot host)) in
727+ isDottedNumeric host && (o1 >= 224 || (o1 == 100 && o2 >= 64 && o2 <= 127) || (o1 == 198 && (o2 == 18 || o2 == 19)))
728+
729+-- True when a dotted-numeric host carries an octet with a redundant leading
730+-- zero (e.g. "0177", "0012"). Canonical IPv4 literals never do, but the system
731+-- resolver behind `fetch` (ureq -> getaddrinfo / inet_aton) reads a leading-zero
732+-- octet as OCTAL: "0012.0.0.1" dials 10.0.0.1 and "0177.0.0.1" dials 127.0.0.1,
733+-- while the decimal octet parse in `isReservedIPv4` (and the dotted-prefix list)
734+-- sees 12.0.0.1 / 177.0.0.1 and waves them through — an SSRF bypass of the same
735+-- class as the non-dotted "dword"/`0x` forms. Reject the ambiguous octal form
736+-- outright. Gated by `isDottedNumeric` so a digit-led hostname label like
737+-- "0foo.example.com" is never caught (it isn't all-digits-and-dots).
738+hasLeadingZeroOctet : Text -> Bool
739+hasLeadingZeroOctet = \host ->
740+ isDottedNumeric host &&
741+ (let octet = beforeDot host in
742+ let rest = afterDot host in
743+ (length octet > 1 && take 1 octet == "0") || (rest != "" && hasLeadingZeroOctet rest))
744+
745+-- True when the host's first two labels name a reserved IPv4 range: the zero net
746+-- (0/8), loopback (127/8), and the RFC1918 blocks 10/8, 172.16/12, 192.168/16,
747+-- plus link-local 169.254/16.
748+--
749+-- Deliberately *not* gated on `isDottedNumeric`, unlike `isReservedIPv4` below:
750+-- these run as leading string labels too, matching the literal `"10."` / `"172.16."`
751+-- prefix tests this replaces. So "10.example.com" stays blocked. A non-numeric
752+-- first label parses to -1 via `decimalValue` and matches nothing, so an ordinary
753+-- hostname is never caught.
754+isReservedOctetPrefix : Text -> Bool
755+isReservedOctetPrefix = \host ->
756+ let o1 = decimalValue (beforeDot host) in
757+ let o2 = decimalValue (beforeDot (afterDot host)) in
758+ o1 == 0 || o1 == 10 || o1 == 127 || (o1 == 172 && o2 >= 16 && o2 <= 31) || (o1 == 192 && o2 == 168) || (o1 == 169 && o2 == 254)
759+
760+-- Block private/reserved/internal hostnames (SSRF defense).
761+--
762+-- LIMITATION — purely lexical: inspects the string, never the resolved address.
763+-- DNS-rebinding TOCTOU and percent-encoding tricks bypass it. A real fix needs
764+-- resolve-then-pin (fuse resolution with connection), which `fetch` doesn't
765+-- expose. See PROTOCOL.md §10. Treat as a guard against naive-literal SSRF, not
766+-- a motivated attacker with a nameserver.
767+--
768+-- Enforced on inbound (`handleRecvGossip`) and at every egress site
769+-- (`sendBatchedGossip`, `retryPendingForwards`, `forkForwardsTo`) as
770+-- defense-in-depth — persisted state from before a denylist tightening must
771+-- not steer a `fetch` at an internal host.
772+--
773+-- A legitimate `host:port` has exactly one `:`, so the multi-colon check
774+-- rejects bare IPv6 literals. The name is trailing-dot-stripped before every
775+-- check so `localhost.` / `foo.internal.` (DNS root form) are caught.
405776isBadServerName : Text -> Bool
406−isBadServerName = \sn -> sn == "" || sn == "localhost" || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [
407− ".",
408− ":",
409− "[",
410− "127.",
411− "10.",
412− "192.168.",
413− "169.254.",
414− "0."
415−] || any (\s -> hasSuffix s sn) [".localhost", ".local", ".internal", ".arpa", ".onion"]
777+isBadServerName = \raw ->
778+ let sn = stripTrailingDot raw in
779+ let host = beforeColon sn in
780+ sn == "" || sn == "localhost" || hasPrefix "localhost:" sn || isNumericHost host || isReservedOctetPrefix host || isReservedIPv4 host || hasLeadingZeroOctet host || hasPrefix "0x" sn || any (\c -> not contains c "0123456789abcdefghijklmnopqrstuvwxyz-.:") (chars sn) || countChar ":" sn > 1 || any (\p -> hasPrefix p sn) [".", ":", "192.0.2.", "198.51.100.", "203.0.113."] || any (\s -> hasSuffix s sn) [".localhost", ".local", ".internal", ".arpa", ".onion"]
416781
417782-- ============================================
418783-- Auth helpers
@@ -424,18 +789,52 @@ isBadServerName = \sn -> sn == "" || sn == "localhost" || any (\c -> not contain
424789-- The `no_presence` check bounds federated forwards to currently-online keys.
425790
426791-- Plain ip match (sessions with empty ip skip the check).
792+--
793+-- Mobile tradeoff: binding a session to an IP means a client that moves between
794+-- networks (Wi-Fi <-> cellular, or a carrier NAT re-assignment mid-session) no
795+-- longer matches its session row, gets a 401, and must re-run the full
796+-- challenge/verify handshake. That is the correct security posture — it is what
797+-- stops a stolen Bearer token from being replayed from anywhere — but it is
798+-- chatty for exactly the clients this protocol targets: phones change networks
799+-- constantly, so re-auth is a routine event, not an exceptional one. Clients are
800+-- expected to treat a 401 as "re-authenticate and retry", not as a fatal error.
801+-- Operators who front the relay with a proxy that does not set a stable
802+-- X-Forwarded-For (or who run with `--trustForwardedFor=False`) get an empty
803+-- bound IP, which disables the check entirely — see `clientIpOf`.
427804matchIp : Text -> Text -> Bool
428805matchIp = \clientIp sessionIp -> sessionIp == "" || clientIp == sessionIp
429806
430−-- Unwrap an X-Forwarded-For header value to a plain IP string. Empty when the
431−-- header is absent (no reverse proxy in front).
807+-- The client IP a session/challenge is bound to: the last (trusted) hop of the
808+-- X-Forwarded-For chain, matching the rate limiter's `pickClientIp`. Taking the
809+-- last hop rather than the whole header means client-supplied spoofed hops don't
810+-- become part of the binding (a trusted proxy appends the real address last).
811+-- Empty when the header is absent or `trustForwardedFor` is off — handlers have
812+-- no access to the raw connection address, and an empty bound IP makes `matchIp`
813+-- skip the check (so IP binding is simply disabled without a trusted proxy).
432814clientIpOf : Maybe Text -> Text
433815clientIpOf = \h -> case h of
434− Just {value: ip} -> ip
816+ Just {value: ips} -> if trustForwardedFor then lastForwardedHop ips else ""
435817 Nothing {} -> ""
436818
437819-- Resolve a Bearer token to its session pubkey if the session is unexpired and
438820-- the client IP matches. Returns Nothing on missing/invalid token.
821+--
822+-- TIMING SIDE-CHANNEL (known gap, not fixable in Knot today). `s.token == token`
823+-- is ordinary string equality, which almost certainly short-circuits on the first
824+-- differing byte. An attacker who can time the response therefore learns how many
825+-- leading characters of a guess were correct, and can in principle extend a guessed
826+-- prefix one hex char at a time instead of searching the whole space. Two things
827+-- bound that today, neither of which is a fix:
828+-- * the token is 48 random hex chars (192 bits, `genToken`), so a blind guess is
829+-- hopeless and the attacker is forced onto the timing channel to make progress;
830+-- * `/poll` and `/messages` are per-IP rate limited, and the linear scan over
831+-- `*sessions` adds noise on the order of the comparison itself, so the number
832+-- of samples needed to resolve a per-byte difference over a network is large.
833+-- Neither argument is a security guarantee — they raise the cost of the attack, they
834+-- do not remove it. The correct fix is a `constantTimeEquals : Text -> Text -> Bool`
835+-- primitive in the Knot runtime (a byte-wise XOR-and-accumulate compare that always
836+-- reads both strings in full); swap the `s.token == token` conjunct for it as soon as
837+-- one exists. See PROTOCOL.md §6, *Error Responses*.
439838authedPubkey : Text -> Int<Ms> -> Text -> IO {r *sessions} (Maybe PubkeyHex)
440839authedPubkey = \authorization t clientIp -> do
441840 ses <- *sessions
@@ -454,6 +853,12 @@ authedPubkey = \authorization t clientIp -> do
454853-- `retryBackoffBase` (capped at `retryBackoffMax`). `shouldRetryServer` is the
455854-- single read-side gate; `recordFailure`/`clearFailure` are the write side.
456855-- There is no half-open state — a single success clears all accumulated failures.
856+-- Concurrent fetches to the same peer can complete out of order, so both
857+-- writers take the attempt-start time and ignore updates older than the
858+-- recorded one (latest-started attempt wins). `clearFailure` therefore writes
859+-- a zero-count row rather than deleting — the timestamp must survive so a
860+-- slower failure can be recognized as stale. Zero-count rows impose no
861+-- backoff and age out of `*forwardFailures` via `pruneExpired`.
457862
458863-- True when enough time has passed since the last failure for `server` to be
459864-- worth retrying (or no failure recorded at all). Linear backoff:
@@ -461,25 +866,51 @@ authedPubkey = \authorization t clientIp -> do
461866shouldRetryServer : Text -> Timestamp -> [ForwardFailure] -> Bool
462867shouldRetryServer = \server t failures -> not any (\f -> f.server == server && t - f.failedAt < min retryBackoffMax (retryBackoffBase * (f.failures : Int))) failures
463868
464−-- Bump the failure counter for `server`.
465−recordFailure : Text -> IO {rw *forwardFailures, clock} {}
466−recordFailure = \server -> do
467− t <- now
468− atomic (do
469− failures <- *forwardFailures
470− let prevCount = case findFirst failures (\f -> f.server == server) of
869+-- Bump the failure counter for `server`. `attemptAt` is when the failed call
870+-- started; the update is skipped when a more recently started attempt has
871+-- already written its outcome.
872+--
873+-- Timestamp ties: `now` has millisecond resolution and `retryPendingForwards`
874+-- reuses a single `t` for a whole sweep, so two concurrent attempts on one peer
875+-- routinely carry the *same* `attemptAt` — and a strict `>` staleness test calls
876+-- neither one stale, making the outcome a plain last-writer-wins race (a success
877+-- and a failure at the same millisecond resolve differently depending on which
878+-- `fetch` returns first). The tiebreaker: at an equal timestamp a success
879+-- outranks a failure, so a zero-count row (the `clearFailure` tombstone) is
880+-- treated as stale here, while `clearFailure` overwrites an equal-timestamped
881+-- failure. Ties now resolve the same way in both orders. The cost is that a
882+-- genuine failure landing in the same millisecond as a success is not counted —
883+-- one skipped backoff step inside one millisecond, versus a permanently
884+-- order-dependent counter.
885+recordFailure : Text -> Timestamp -> IO {rw *forwardFailures} {}
886+recordFailure = \server attemptAt -> atomic (do
887+ failures <- *forwardFailures
888+ let prev = findFirst failures (\f -> f.server == server)
889+ let stale = case prev of
890+ Just {value: f} -> f.failedAt > attemptAt || (f.failedAt == attemptAt && f.failures == 0)
891+ _ -> False {}
892+ when (not stale) (do
893+ let prevCount = case prev of
471894 Just {value: f} -> f.failures
472895 _ -> 0
473896 *forwardFailures = union (filter (\f -> f.server != server) failures) [
474− {server, failedAt: t, failures: prevCount + 1}
475− ])
476−
477−-- Drop the failure record for `server` (called after any successful peer call).
478−-- Single-success recovery — there is no half-open state.
479−clearFailure : Text -> IO {rw *forwardFailures} {}
480−clearFailure = \server -> atomic (do
481− forwardFailures <- *forwardFailures
482− *forwardFailures = filter (\f -> f.server != server) forwardFailures)
897+ {server, failedAt: attemptAt, failures: prevCount + 1}
898+ ]))
899+
900+-- Reset the failure counter for `server` to zero (called after any successful
901+-- peer call; single-success recovery — there is no half-open state). Keeps a
902+-- zero-count row so its timestamp can outrank slower concurrent failures.
903+-- The staleness test stays strict (`>`) on purpose: at an equal timestamp this
904+-- write wins, which is the other half of the tie rule described on
905+-- `recordFailure`.
906+clearFailure : Text -> Timestamp -> IO {rw *forwardFailures} {}
907+clearFailure = \server attemptAt -> atomic (do
908+ failures <- *forwardFailures
909+ let stale = any (\f -> f.server == server && f.failedAt > attemptAt) failures
910+ when (not stale) (do
911+ *forwardFailures = union (filter (\f -> f.server != server) failures) [
912+ {server, failedAt: attemptAt, failures: 0}
913+ ]))
483914
484915-- ============================================
485916-- Common helpers
@@ -494,24 +925,93 @@ hasLocalPresence = \pres pk t -> any (\p -> p.pubkey == pk && isLocalServer p.se
494925
495926-- Project items through `getServer`, drop our own hostname, and dedupe.
496927-- Used to derive peer recipient lists from forwards/presence/etc.
928+--
929+-- Returns `[Text]`, the base type of `ServerName` — see `type ServerName` for why
930+-- server names stay unrefined everywhere in state.
497931collectPeers : [a] -> (a -> Text) -> [Text]
498932collectPeers = \items getServer -> filter (\s -> not isLocalServer s) (map getServer items)
499933
934+-- Current sequence value, or 0 before the first message seeds it. Reads the
935+-- 0-or-1-row `*seqCounter` relation and folds out the value with a 0 default,
936+-- rather than unwrapping a (possibly empty) scalar source — which the runtime
937+-- panics on.
938+currentSeq : [{value: Timestamp}] -> Timestamp
939+currentSeq = \rows -> fold (\acc r -> max acc r.value) (0 : Timestamp) rows
940+
500941-- Append one ciphertext to the local mailbox under a fresh monotonic
501942-- sequence. Row-level STM retry on `*messages` wakes any parked /poll
502943-- transaction whose filter matches the new row. Caller wraps in `atomic`.
944+--
945+-- Idempotent on (toKey, encryptedBlob): an identical ciphertext already in the
946+-- mailbox is left alone rather than appended again. Both ingress paths need this
947+-- — a client retry after a lost response (`handleSendMessages`) and a peer's
948+-- re-forward after a lost 200 (`handleForwardMessage`) carry the identical blob,
949+-- and the fresh `seqTs` below would otherwise make `union` keep both rows and
950+-- double-deliver. Re-reading `*messages` per call also collapses duplicate blobs
951+-- appearing twice within one batch.
952+--
953+-- N3: This O(n) scan is a CPU amplification vector. A batch of 100 identical
954+-- blobs to the same recipient triggers 100 × O(|messages|) comparisons. With
955+-- |messages| = 10000, that's 1M comparisons per request, bounded by the 60/min
956+-- rate limit. A real fix needs an index on (toKey, encryptedBlob) or a
957+-- hash-based dedup set — both require runtime support not available in Knot
958+-- today. Documented as an accepted DoS surface.
503959appendMessage : PubkeyHex -> BlobHex -> Int<Ms> -> IO {rw *messages, rw *seqCounter} {}
504960appendMessage = \toKey encryptedBlob t -> do
505961 msgs <- *messages
506− seq <- *seqCounter
507− let seqTs = max t (seq + 1)
508− *seqCounter = seqTs
509− *messages = union msgs [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}]
510−
511−-- True when `f` is the queued forward of `blob` to `server`. Used to dedupe
512−-- per-(blob, server) entries on the forwards outbox.
513−forwardMatches : BlobHex -> ServerName -> ForwardEntry -> Bool
514−forwardMatches = \blob server f -> f.encryptedBlob == blob && f.toServer == server
962+ when (not any (\m -> m.toKey == toKey && m.encryptedBlob == encryptedBlob) msgs) (do
963+ seqRows <- *seqCounter
964+ let seqTs = max t (currentSeq seqRows + 1)
965+ *seqCounter = [{value: seqTs}]
966+ -- Enforce `maxMessagesPerRecipient` before the append: evict the oldest rows
967+ -- for this recipient so the post-append count lands exactly on the ceiling.
968+ -- `- 1` reserves the slot the new row is about to take.
969+ --
970+ -- S4: Instead of building an `evicted` list and checking membership (which
971+ -- is O(|messages| × |evicted|)), compute the eviction threshold — the
972+ -- receivedAt of the (maxMessagesPerRecipient - 1)th oldest message for this
973+ -- recipient — and keep only messages newer than it. This is O(|messages|)
974+ -- with no inner scan.
975+ let forRecipient = filter (\m -> m.toKey == toKey) msgs
976+ let excess = max 0 (count forRecipient - (maxMessagesPerRecipient - 1))
977+ let sortedRecipient = sortBy (\m -> m.receivedAt) forRecipient
978+ -- The threshold: the receivedAt of the last row we want to evict.
979+ -- Rows with receivedAt <= threshold are evicted; rows > threshold survive.
980+ let evictionThreshold = if excess > 0
981+ then case take excess sortedRecipient of
982+ [] -> 0
983+ xs -> case last xs of
984+ {receivedAt: r} -> r
985+ else 0
986+ let kept = filter (\m -> not (m.toKey == toKey && m.receivedAt <= evictionThreshold && excess > 0)) msgs
987+ -- Global cap (S6): after the per-recipient eviction, bound the total mailbox
988+ -- across all recipients by dropping the globally-oldest rows. Only runs on
989+ -- overflow, so the O(n log n) sort is off the hot path. Dropped rows may
990+ -- belong to other recipients; their orphaned *forwards are reclaimed by
991+ -- `pruneExpired`, and their lost `deliveredTo` is harmless (the blob is gone).
992+ let globallyCapped = if count kept > maxMessagesTotal
993+ then drop (count kept - maxMessagesTotal) (sortBy (\m -> m.receivedAt) kept)
994+ else kept
995+ *messages = union globallyCapped [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}])
996+
997+-- Record that `server` has accepted a federated forward of this message, so a
998+-- later presence re-announce of `toKey` doesn't re-forward the same blob to a peer
999+-- that already has it. No-op when the peer is already listed. Caller wraps in
1000+-- `atomic`.
1001+markDelivered : PubkeyHex -> BlobHex -> Text -> IO {rw *messages} {}
1002+markDelivered = \toKey blob server -> do
1003+ msgs <- *messages
1004+ *messages = map (\m -> if m.toKey == toKey && m.encryptedBlob == blob && not any (\d -> d.server == server) m.deliveredTo
1005+ then {m.toKey, m.encryptedBlob, m.receivedAt, deliveredTo: union m.deliveredTo [{server}]}
1006+ else m) msgs
1007+
1008+-- True when `f` is the queued forward of `blob` to `toKey` at `server`. Used to
1009+-- dedupe entries on the forwards outbox. All three fields matter: the same blob
1010+-- can legitimately be queued to the same peer for two *different* recipients (a
1011+-- sender fanning one ciphertext out to several keys), and matching on
1012+-- (blob, server) alone would let completing one of them drop the others.
1013+forwardMatches : PubkeyHex -> BlobHex -> Text -> ForwardEntry -> Bool
1014+forwardMatches = \toKey blob server f -> f.toKey == toKey && f.encryptedBlob == blob && f.toServer == server
5151015
5161016-- Replace any local presence rows for `pubkey` with one fresh row at
5171017-- `expiry`, and return the post-update peer broadcast set. Caller wraps in
@@ -527,27 +1027,40 @@ refreshLocalPresence = \pubkey expiry -> do
5271027-- ============================================
5281028-- Pruning
5291029-- ============================================
530−-- Single sweep that drops expired entries from every state ref in one atomic
531−-- block. Returns the locally-expired presence rows whose pubkey has no other
532−-- live local session, so the caller can broadcast offline gossip for them.
5331030
5341031-- One sweep over every state ref: drops expired rows in a single atomic block.
5351032-- Returns the locally-expired presence rows whose pubkey has no other live
5361033-- local session, so the caller can broadcast offline gossip for them.
537−pruneExpired : Int<Ms> -> IO {rw *challenges, rw *forwardFailures, rw *messages, rw *presence, rw *sessions} [PubkeyHex]
1034+-- Also prunes orphaned *forwards entries whose message has been removed by
1035+-- the TTL sweep — a 404-deferred forward (recipient never online at the peer)
1036+-- would otherwise stay forever with retries=0, retried every sweep indefinitely.
1037+pruneExpired : Int<Ms> -> IO {rw *challenges, rw *forwardFailures, rw *forwards, rw *messages, rw *presence, rw *sessions} [PubkeyHex]
5381038pruneExpired = \t -> atomic do
5391039 ch <- *challenges
5401040 *challenges = filter (\x -> x.expiresAt > t) ch
5411041 ses <- *sessions
5421042 *sessions = filter (\x -> x.expiresAt > t) ses
5431043 msgs <- *messages
544− *messages = filter (\m -> t - m.receivedAt < messageMaxAge) msgs
1044+ let surviving = filter (\m -> t - m.receivedAt < messageMaxAge) msgs
1045+ *messages = surviving
1046+ -- Prune forwards whose message was removed by the TTL sweep above.
1047+ -- ForwardEntry carries no timestamp, so match against the surviving
1048+ -- messages (the post-sweep list, not the pre-sweep snapshot).
1049+ --
1050+ -- S5: This is O(|forwards| × |surviving|) — a quadratic scan. In practice
1051+ -- |forwards| is usually far below maxForwardsTotal (50000) and |surviving|
1052+ -- is bounded by maxMessagesTotal (1000000), so the cost is modest. A real
1053+ -- fix needs a set/index keyed on (toKey, encryptedBlob), which the Knot
1054+ -- runtime does not expose today. Documented as an accepted design tradeoff.
1055+ fw <- *forwards
1056+ *forwards = filter (\f -> any (\m -> m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob) surviving) fw
5451057 ff <- *forwardFailures
5461058 *forwardFailures = filter (\f -> t - f.failedAt < forwardFailureMaxAge) ff
5471059 -- presence: remember locally-expired keys for offline gossip
5481060 pres <- *presence
549− *presence = filter (\p -> p.expiresAt > t) pres
550− let liveLocalPubkeys = map (\p -> p.pubkey) (filter (\p -> isLocalServer p.server && p.expiresAt > t) pres)
1061+ let live = filter (\p -> p.expiresAt > t) pres
1062+ *presence = live
1063+ let liveLocalPubkeys = map (\p -> p.pubkey) (filter (\p -> isLocalServer p.server) live)
5511064 yield (map (\p -> p.pubkey) (filter (\p -> p.expiresAt <= t && isLocalServer p.server && not elem p.pubkey liveLocalPubkeys) pres))
5521065
5531066-- ============================================
@@ -557,18 +1070,40 @@ pruneExpired = \t -> atomic do
5571070-- a peer record into `forwardFailures` (shared with forward retries) so both
5581071-- subsystems back off together.
5591072
560−-- Fan out one bundle of gossip events to each peer, skipping peers in backoff.
561−-- No-op when `events` is empty so callers don't need to guard. Records or
562−-- clears federation-failure counters per call.
563−sendBatchedGossip : [Text] -> [GossipEventType] -> IO {rw *forwardFailures, network, clock} {}
564−sendBatchedGossip = \peers events -> when (count events > 0) (do
565− t <- now
566− failures <- *forwardFailures
567− forEach peers (\server -> when (shouldRetryServer server t failures) (do
568− result <- fetch (federationUrl server) (RecvGossip {events, fromServer: serverHost})
569− case result of
570− Err _ -> recordFailure server
571− Ok _ -> clearFailure server)))
1073+-- The gossip fan-out set: peers learned from `*presence` plus the configured
1074+-- bootstrap seeds, minus ourselves.
1075+--
1076+-- This union is the whole bootstrap mechanism (see `seedPeers`). Seeds are *not*
1077+-- written into `*presence` to make them stick: a `PresenceEntry` is keyed by the
1078+-- pubkey it announces, and a seed peer is a hostname we know nothing else about —
1079+-- there is no pubkey to key a row on, and inventing one would put a phantom key
1080+-- into the very table `forwardQueuedToServer` trusts to route mail. Folding seeds
1081+-- in at fan-out time instead means they are gossip *targets* without ever being
1082+-- presence *facts*. Once a seed answers, its own gossip creates the real presence
1083+-- rows, and from then on it is reachable through the `peers` half of this union
1084+-- like any other peer.
1085+gossipTargets : [Text] -> [Text]
1086+gossipTargets = \peers -> union peers seedPeerList
1087+
1088+-- Fan out one bundle of gossip events to each peer (presence-learned peers and
1089+-- bootstrap seeds alike), skipping denylisted peers and peers in backoff. No-op
1090+-- when `events` is empty so callers don't need to guard. Records or clears
1091+-- federation-failure counters per call.
1092+--
1093+-- Also a no-op when `serverHost` fails the `ServerName` predicate: the gossip
1094+-- body must carry a valid `fromServer`, and a peer would reject (400) a name we
1095+-- could not have refined anyway. `main` warns about this at startup.
1096+sendBatchedGossip : [Text] -> [GossipEvent] -> IO {rw *forwardFailures, network, clock} {}
1097+sendBatchedGossip = \peers events -> case localServerName of
1098+ Nothing {} -> yield {}
1099+ Just {value: me} -> when (count events > 0) (do
1100+ t <- now
1101+ failures <- *forwardFailures
1102+ forEach (gossipTargets peers) (\server -> when (not (isBadServerName server) && shouldRetryServer server t failures) (do
1103+ result <- fetch (federationUrl server) (RecvGossip {events, fromServer: me})
1104+ case result of
1105+ Err _ -> recordFailure server t
1106+ Ok _ -> clearFailure server t)))
5721107
5731108-- ============================================
5741109-- Forward retries
@@ -579,49 +1114,149 @@ sendBatchedGossip = \peers events -> when (count events > 0) (do
5791114-- comes online: any messages already buffered for that pubkey are enqueued
5801115-- as forwards to the newly-known server.
5811116
582−-- Walk pending forwards grouped by destination, retry each (up to maxBatchSize
583−-- per peer) using `/federation/forward`. Drops successful entries and records
584−-- the peer on the message's `deliveredTo`; for failures bumps the retry count
585−-- and gives up after `maxForwardRetries`.
586−retryPendingForwards : IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
587−retryPendingForwards = do
588− t <- now
589− fwds <- *forwards
590− failures <- *forwardFailures
591− forEach (filter (\s -> shouldRetryServer s t failures) (collectPeers fwds (\f -> f.toServer))) (\server -> forEach (take maxBatchSize (filter (\f -> f.toServer == server) fwds)) (\f -> do
1117+-- Attempt every forward in `batch` to one peer, in order, and report both whether
1118+-- any attempt was a genuine peer failure (`anyFailed`) and whether any attempt
1119+-- genuinely succeeded (`anySucceeded`). Drops successful entries from `*forwards`
1120+-- and records the peer on the message's `deliveredTo`; every attempt (success,
1121+-- failure, or 404) bumps the retry count and gives up after `maxForwardRetries`.
1122+--
1123+-- INVARIANT — this must not touch `*forwardFailures`. The caller records at most one
1124+-- failure per peer per sweep, from the `anyFailed` flag returned here; recording per
1125+-- failing *message* would let one unreachable peer with N buffered messages take N
1126+-- backoff steps in a single sweep and pin itself at `retryBackoffMax` for
1127+-- `forwardFailureMaxAge`. The counter counts consecutive failed *sweeps*.
1128+--
1129+-- A 404 is NOT a peer failure. `handleForwardMessage` answers 404 `no_presence`
1130+-- whenever the recipient is not *currently* online at that peer, which is an
1131+-- expected, retryable outcome (PROTOCOL.md §7) — the peer is healthy and answering.
1132+-- `fetch` returns `Err` for every status >= 400, so treating any `Err` as a failure
1133+-- would feed a routine 404 into `recordFailure`, and `*forwardFailures` is shared
1134+-- with gossip: one stale-presence 404 would suppress all forwards *and* all gossip to
1135+-- that peer for a full backoff step (30 s–8 min). So a 404 leaves the peer's backoff
1136+-- untouched (`anyFailed` stays false for it) and never clears it (`anySucceeded`
1137+-- stays false too).
1138+--
1139+-- But a 404 DOES bump the forward's retry count now (S1). It previously left the row
1140+-- entirely untouched, so a forward whose recipient never came online at the peer was
1141+-- retried every sweep for the full 30-day message TTL — the counter never advanced,
1142+-- so `maxForwardRetries` was never reached. Counting the attempt (without charging
1143+-- the peer's backoff) lets the forward hit the cap and be dropped after
1144+-- `maxForwardRetries` sweeps while still not penalizing a healthy peer.
1145+--
1146+-- Recursive rather than `forEach` because the result has to be accumulated across
1147+-- iterations; `sortBy` fixes the iteration order that `head`/`drop 1` then walk.
1148+retryForwardsTo : Text -> [ForwardEntry] -> IO {rw *forwards, rw *messages, network} {anyFailed: Bool, anySucceeded: Bool}
1149+retryForwardsTo = \server batch -> case head batch of
1150+ Nothing {} -> yield {anyFailed: False {}, anySucceeded: False {}}
1151+ Just {value: f} -> do
5921152 result <- fetch (federationUrl server) (ForwardMessage {f.toKey, f.encryptedBlob})
593− success <- case result of
594− Err _ -> do
595− recordFailure server
596− yield (False {})
597− Ok _ -> do
598− clearFailure server
599− yield (True {})
600− let isMatch = forwardMatches f.encryptedBlob server
1153+ let status = case result of
1154+ Err {error: e} -> e.status
1155+ Ok _ -> 200
1156+ let success = status < 400
1157+ -- Recipient simply isn't online at the peer yet: hold the peer blameless, but
1158+ -- still count the attempt so the row can't outlive the message TTL (S1).
1159+ let deferred = status == 404
1160+ let isMatch = forwardMatches f.toKey f.encryptedBlob server
6011161 let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
6021162 atomic (do
6031163 cur <- *forwards
1164+ -- Count the attempt regardless of outcome: a 404 still consumes a retry slot
1165+ -- so a forward that never finds the recipient online is eventually dropped
1166+ -- (S1). On success or after `maxForwardRetries`, `isDone` filters the row out
1167+ -- first, so the increment then no-ops on an absent row.
6041168 *forwards = map (\x -> if isMatch x
6051169 then {x.toServer, x.toKey, x.encryptedBlob, retries: x.retries + 1}
6061170 else x) (filter (\x -> not isDone x) cur)
607− when success (do
608− msgs <- *messages
609− *messages = map (\m -> if m.toKey == f.toKey && m.encryptedBlob == f.encryptedBlob && not elem server m.deliveredTo
610− then {m.toKey, m.encryptedBlob, m.receivedAt, deliveredTo: union m.deliveredTo [server]}
611− else m) msgs))))
1171+ when success (markDelivered f.toKey f.encryptedBlob server))
1172+ rest <- retryForwardsTo server (drop 1 batch)
1173+ yield {anyFailed: (not success && not deferred) || rest.anyFailed, anySucceeded: success || rest.anySucceeded}
1174+
1175+-- Walk pending forwards grouped by destination and retry each peer's backlog (up
1176+-- to maxBatchSize per peer) via `/federation/forward`. One failure record per peer
1177+-- per sweep: a sweep with any genuine failure takes exactly one backoff step; a
1178+-- sweep with at least one genuine success clears the peer's backoff; an all-404
1179+-- sweep leaves the backoff untouched, neither charging nor clearing it (S1). See
1180+-- `retryForwardsTo`.
1181+retryPendingForwards : IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
1182+retryPendingForwards = do
1183+ t <- now
1184+ fwds <- *forwards
1185+ failures <- *forwardFailures
1186+ forEach (collectPeers fwds (\f -> f.toServer)) (\server -> if isBadServerName server
1187+ -- Denylisted destination (state persisted before a denylist tightening):
1188+ -- its forwards would never be attempted, so drop them instead of letting
1189+ -- them sit in the outbox forever. The denylist check stays outside the
1190+ -- atomic predicate — only the plain equality filter is pushed down.
1191+ then atomic (do
1192+ cur <- *forwards
1193+ *forwards = filter (\x -> x.toServer != server) cur)
1194+ else when (shouldRetryServer server t failures) (do
1195+ -- INVARIANT — `[a]` is an unordered set, so the backlog must be sorted before
1196+ -- `take maxBatchSize` or the batch is an implementation-defined subset that
1197+ -- could keep retrying the same entries while starving others past the cap. A
1198+ -- stable key drains a fixed prefix as entries succeed (removed) or hit
1199+ -- `maxForwardRetries` (dropped). `ForwardEntry` carries no timestamp, so key on
1200+ -- the unique blob.
1201+ let batch = take maxBatchSize (sortBy (\f -> f.encryptedBlob) (filter (\f -> f.toServer == server) fwds))
1202+ result <- retryForwardsTo server batch
1203+ -- Clear the peer's backoff only on a sweep with at least one genuine success
1204+ -- (S1). An all-404 sweep (recipient not online at the peer) is neither a
1205+ -- failure nor a success: clearing backoff on it would reset the peer to
1206+ -- "fresh" every sweep, firing a retry every `backgroundPruneInterval` for the
1207+ -- full 30-day message TTL. `count batch > 0` also guards against reading an
1208+ -- empty sweep (possible with a startup-overridden `maxBatchSize` of 0) as
1209+ -- meaningful.
1210+ if result.anyFailed
1211+ then recordFailure server t
1212+ else when (count batch > 0 && result.anySucceeded) (clearFailure server t)))
6121213
6131214-- Forward queued messages to a server when a remote key comes online.
6141215-- Skips messages already delivered to `server` (tracked via Message.deliveredTo)
6151216-- so a presence re-announce after a TTL lapse doesn't duplicate delivery.
1217+--
1218+-- SECURITY: this is the function the gossip-redirect attack in the section header
1219+-- above actually cashes out through — it routes on `*presence`, which any
1220+-- unauthenticated host can write. Do not add a caller that widens what this
1221+-- forwards (e.g. dropping the `deliveredTo` filter) without revisiting that note.
6161222forwardQueuedToServer : PubkeyHex -> ServerName -> IO {rw *forwards, r *messages} {}
617−forwardQueuedToServer = \pubkey server -> atomic (do
1223+forwardQueuedToServer = \pubkey serverName -> atomic (do
1224+ let server = (serverName : Text)
6181225 msgs <- *messages
6191226 fwds <- *forwards
620− let existing = count (filter (\f -> f.toServer == server) fwds)
1227+ let existing = countWhere (\f -> f.toServer == server) fwds
6211228 let budget = max 0 (maxForwardsPerServer - existing)
622− let candidates = filter (\m -> m.toKey == pubkey && not any (forwardMatches m.encryptedBlob server) fwds && not elem server m.deliveredTo) msgs
1229+ let notYetForwarded = \m -> not any (forwardMatches m.toKey m.encryptedBlob server) fwds
1230+ let notYetDelivered = \m -> not any (\d -> d.server == server) m.deliveredTo
1231+ let candidates = filter (\m -> m.toKey == pubkey && notYetForwarded m && notYetDelivered m) msgs
6231232 let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
624− *forwards = union fwds newFwds)
1233+ *forwards = capForwards (union fwds newFwds))
1234+
1235+-- Batched version of `forwardQueuedToServer`: stage forwards for *all* newly-online
1236+-- keys in a single mailbox scan (S4). Calling `forwardQueuedToServer` per key scans
1237+-- `*messages` once per key, which is why `handleRecvGossip` previously capped the
1238+-- number of keys at `maxNewlyOnlineForwards` (N13) — but that cap silently deferred
1239+-- delivery past the 10th key, and a re-announce within the TTL never recovered it
1240+-- (the `deliveredTo`/presence state made the key look already-handled). A single
1241+-- pass bounds the cost to O(|messages|) regardless of how many keys came online.
1242+--
1243+-- SECURITY: shares `forwardQueuedToServer`'s exposure — it routes on gossip-written
1244+-- presence. The same `deliveredTo` / `notYetForwarded` / per-peer budget guards
1245+-- apply; do not widen them without revisiting the gossip-redirect note above.
1246+forwardQueuedToAll : [PubkeyHex] -> ServerName -> IO {rw *forwards, r *messages} {}
1247+forwardQueuedToAll = \pubkeys serverName -> atomic (do
1248+ let server = (serverName : Text)
1249+ msgs <- *messages
1250+ fwds <- *forwards
1251+ let existing = countWhere (\f -> f.toServer == server) fwds
1252+ let budget = max 0 (maxForwardsPerServer - existing)
1253+ let notYetForwarded = \m -> not any (forwardMatches m.toKey m.encryptedBlob server) fwds
1254+ let notYetDelivered = \m -> not any (\d -> d.server == server) m.deliveredTo
1255+ -- `elem m.toKey pubkeys` is O(|pubkeys|), but `pubkeys` is bounded by
1256+ -- `maxGossipEvents` (100), so the scan stays O(|messages|) overall.
1257+ let candidates = filter (\m -> elem m.toKey pubkeys && notYetForwarded m && notYetDelivered m) msgs
1258+ let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
1259+ *forwards = capForwards (union fwds newFwds))
6251260
6261261-- ============================================
6271262-- Handler: challenge / verify
@@ -637,58 +1272,118 @@ forwardQueuedToServer = \pubkey server -> atomic (do
6371272-- revoke/logout.
6381273
6391274-- POST /auth/challenge: issue a fresh signed-nonce challenge for `pubkey`.
640−-- Capped per-key (maxChallengesPerKey); per-pubkey rate limiting is enforced
1275+-- Capped per-key (maxChallengesPerKey); per-client-IP rate limiting is enforced
6411276-- by the route's `rateLimit` clause. The IP is recorded so /auth/verify can
6421277-- require the same client.
1278+--
1279+-- A full pool LRU-evicts rather than rejecting, exactly as `handleVerifyAuth` does
1280+-- for `maxSessions`. Answering 503 once `maxChallenges` is reached would hand any
1281+-- attacker who can fill the pool a total login outage — nobody can start a
1282+-- handshake, including clients whose challenges are already in flight. Evicting the
1283+-- soonest-to-expire challenge instead degrades to "an abandoned challenge may be
1284+-- dropped before its 60 s window elapses", which costs the affected client one
1285+-- retry. The freshly-issued row always survives its own `capList`: it carries the
1286+-- latest `expiresAt` of any live challenge by construction.
6431287handleChallenge : PubkeyHex -> Text -> IO {rw *challenges, clock, random} (Result HttpError {challenge: Text, expiresAt: Timestamp})
6441288handleChallenge = \pubkey clientIp -> do
6451289 t <- now
646− challenge <- genToken 48
1290+ challenge <- genToken 48 ""
6471291 let expiresAt = t + challengeExpiry
6481292 atomic (do
6491293 ch <- *challenges
6501294 let cappedActive = capPerKey (filter (\c -> c.expiresAt > t) ch) pubkey maxChallengesPerKey
651− if count cappedActive >= maxChallenges
652− then yield (Err {error: {status: 503, message: "capacity"}})
653− else do
654− *challenges = union cappedActive [{pubkey, challenge, expiresAt, ip: clientIp}]
655− yield (Ok {value: {challenge, expiresAt}}))
1295+ *challenges = capList (union cappedActive [{pubkey, challenge, expiresAt, ip: clientIp}]) maxChallenges
1296+ yield (Ok {value: {challenge, expiresAt}}))
6561297
6571298-- POST /auth/verify: validate `signature` over `challenge` for `pubkey`.
6581299-- On success, mints a session token, records local presence (`pubkey` is now
6591300-- live on this server), and gossips an `online` event to known peers. If
6601301-- `revokeOthers` is set, all other sessions for the same pubkey are dropped.
661−handleVerifyAuth : PubkeyHex -> Text -> Text -> Bool -> Text -> IO {rw *challenges, rw *forwardFailures, rw *presence, rw *sessions, network, clock, random} (Result HttpError {expiresAt: Timestamp, token: Text})
1302+handleVerifyAuth : PubkeyHex -> Text -> Text -> Bool -> Text -> IO {rw *challenges, rw *forwardFailures, rw *presence, rw *sessions, network, clock, random, console} (Result HttpError {expiresAt: Timestamp, token: Text})
6621303handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
6631304 t <- now
6641305 let expiry = t + sessionExpiry
665− token <- genToken 48
666− let sigValid = case bytesFromHex pubkey of
667− Just {value: pk} -> case bytesFromHex signature of
668− Just {value: sigBytes} -> verify pk (textToBytes challenge) sigBytes
669− _ -> False {}
670− _ -> False {}
671− result <- atomic (do
1306+ token <- genToken 48 ""
1307+ -- The client signs (target host, challenge) under a domain-separation tag so a
1308+ -- relay server cannot replay the signature to a different server. We verify
1309+ -- against our own configured hostname (port stripped), which a relay cannot forge.
1310+ --
1311+ -- Signature verification is split out of the session-install transaction (S7).
1312+ -- Phase 1 is a cheap read-only atomic that confirms a matching, unexpired,
1313+ -- IP-bound challenge exists — the original CPU-DoS guard, which still spends no
1314+ -- Ed25519 CPU on /auth/verify calls that have no matching challenge. Phase 2 runs
1315+ -- the expensive `verify` *outside* any atomic, so an STM retry on the phase-3
1316+ -- install transaction (triggered by concurrent writes to *challenges/*sessions)
1317+ -- never re-executes it — previously `verify` sat inside that transaction and every
1318+ -- retry re-ran the Ed25519 check. Phase 3 re-checks the challenge before consuming
1319+ -- it, closing the TOCTOU window opened between phases 1 and 3.
1320+ let signedText = "skrepka-auth-v1:" ++ authHost serverHost ++ ":" ++ challenge
1321+ challengeOk <- atomic (do
6721322 ch <- *challenges
6731323 let same = \c -> c.pubkey == pubkey && c.challenge == challenge
674− let valid = any (\c -> same c && c.expiresAt > t && matchIp clientIp c.ip) ch && sigValid
675− if not valid
676− then yield (Nothing {})
1324+ case findFirst ch same of
1325+ Nothing {} -> yield (False {})
1326+ Just {value: c} -> yield (c.expiresAt > t && matchIp clientIp c.ip))
1327+ if not challengeOk
1328+ then yield (Err {error: {status: 401, message: "invalid"}})
1329+ else do
1330+ -- Phase 2: verify OUTSIDE any atomic. Expensive, and must not be re-executed on
1331+ -- an STM retry of the install transaction below (S7).
1332+ let sigValid = case bytesFromHex pubkey of
1333+ Just {value: pk} -> case bytesFromHex signature of
1334+ Just {value: sigBytes} -> verify pk (textToBytes signedText) sigBytes
1335+ _ -> False {}
1336+ _ -> False {}
1337+ if not sigValid
1338+ then yield (Err {error: {status: 401, message: "invalid"}})
6771339 else do
678− *challenges = filter (\c -> not same c) ch
679− ses <- *sessions
680− let pruned = if revokeOthers
681− then filter (\s -> s.pubkey != pubkey) ses
682− else capPerKey ses pubkey maxSessionsPerKey
683− let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
684− *sessions = capList (union pruned [newSession]) maxSessions
685− peers <- refreshLocalPresence pubkey expiry
686− yield (Just {value: peers}))
687− case result of
688− Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
689− Just {value: peers} -> do
690− when federationEnabled (fork (sendBatchedGossip peers [GossipOnline {pubkey}]))
691− yield (Ok {value: {token, expiresAt: expiry}})
1340+ -- Phase 3: re-check the challenge (it may have expired or been consumed by a
1341+ -- concurrent verify since phase 1), consume it, and install the session. The
1342+ -- body is cheap, so an STM retry here costs almost nothing.
1343+ result <- atomic (do
1344+ ch <- *challenges
1345+ let same = \c -> c.pubkey == pubkey && c.challenge == challenge
1346+ let matchingChallenge = findFirst ch same
1347+ case matchingChallenge of
1348+ Nothing {} -> yield (Nothing {})
1349+ Just {value: c} -> do
1350+ if not (c.expiresAt > t && matchIp clientIp c.ip)
1351+ then yield (Nothing {})
1352+ else do
1353+ -- Consume the challenge and optionally revoke other sessions + challenges.
1354+ -- When `revokeOthers` is set, drop all other sessions AND outstanding
1355+ -- challenges for this pubkey, so a captured challenge cannot be
1356+ -- redeemed for a new session after revocation (Q35).
1357+ *challenges = if revokeOthers
1358+ then filter (\x -> not (x.pubkey == pubkey)) ch
1359+ else filter (\x -> not same x) ch
1360+ ses <- *sessions
1361+ let pruned = if revokeOthers
1362+ then filter (\s -> s.pubkey != pubkey) ses
1363+ else capPerKey ses pubkey maxSessionsPerKey
1364+ let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
1365+ -- LRU-evicts the soonest-to-expire session when the pool is full; this global
1366+ -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
1367+ let pooled = union pruned [newSession]
1368+ -- Count what `capList` is about to shed, so the handler can warn about it
1369+ -- below. That flood has no other observable signature: evicted users just
1370+ -- start seeing 401s and silently re-authenticate, which looks like normal
1371+ -- traffic in the access log. A sustained nonzero rate here is the alert.
1372+ let evicted = max 0 (count pooled - maxSessions)
1373+ *sessions = capList pooled maxSessions
1374+ peers <- refreshLocalPresence pubkey expiry
1375+ yield (Just {value: {peers, evicted}}))
1376+ case result of
1377+ Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
1378+ Just {value: r} -> do
1379+ -- Warn outside the `atomic` block, never inside: an STM transaction can
1380+ -- retry, and a console write is not rolled back with it — logging inside
1381+ -- would emit one line per retry attempt rather than one per eviction.
1382+ -- Include the requesting pubkey and IP for audit (Q38): an operator
1383+ -- investigating a Sybil flood needs to know which identities are responsible.
1384+ when (r.evicted > 0) (logWarn ("session pool full: evicted " ++ show r.evicted ++ " session(s) to seat a new login (pubkey=" ++ pubkey ++ ", ip=" ++ clientIp ++ ", maxSessions=" ++ show maxSessions ++ ")"))
1385+ when federationEnabled (fork (sendBatchedGossip r.peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
1386+ yield (Ok {value: {token, expiresAt: expiry}})
6921387
6931388-- ============================================
6941389-- Handler: poll
@@ -703,7 +1398,7 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
7031398-- don't time us out mid-session, then runs `doPoll` to collect events (or
7041399-- block until a matching message arrives or the timeout elapses) and
7051400-- assembles the response.
706−handlePoll : Int -> Text -> Text -> IO {r *sessions, rw *forwardFailures, rw *forwards, rw *messages, rw *presence, network, clock} (Result HttpError {cursor: Timestamp, events: [PollEvent]})
1401+handlePoll : Timestamp -> Text -> Text -> IO {r *seqCounter, r *sessions, rw *forwardFailures, rw *forwards, rw *messages, rw *presence, network, clock} (Result HttpError {cursor: Timestamp, events: [PollEvent]})
7071402handlePoll = \cursor authorization clientIp -> do
7081403 t <- now
7091404 setup <- atomic (do
@@ -711,16 +1406,32 @@ handlePoll = \cursor authorization clientIp -> do
7111406 case auth of
7121407 Nothing {} -> yield (Nothing {})
7131408 Just {value: pubkey} -> do
714− let clampedCursor = max 0 (min t cursor)
1409+ -- Clamp the client cursor to the highest assigned sequence
1410+ -- (`*seqCounter`), not wall-clock `t`. Under sustained high throughput
1411+ -- `receivedAt` (= `seqTs`) can run ahead of `t`; clamping to `t` would
1412+ -- drag a legitimately-returned cursor backward and re-deliver the
1413+ -- in-between messages. `max t curSeq` keeps the old behaviour when the
1414+ -- counter is idle/behind, while still refusing to ack beyond what's
1415+ -- been issued.
1416+ curSeqRows <- *seqCounter
1417+ let curSeq = currentSeq curSeqRows
1418+ let clampedCursor = max 0 (min (max t curSeq) cursor)
7151419 when (clampedCursor > 0) (ackPolledMessages pubkey clampedCursor)
7161420 pres <- *presence
1421+ -- Refresh local presence when less than half the session TTL remains,
1422+ -- so peers don't time us out mid-session.
7171423 let stale = not any (\p -> p.pubkey == pubkey && isLocalServer p.server && p.expiresAt - t >= sessionExpiry / 2) pres
7181424 refreshPeers <- if stale then refreshLocalPresence pubkey (t + sessionExpiry) else yield []
719− yield (Just {value: {pubkey, clampedCursor, refreshPeers}}))
1425+ -- S3: Track whether we actually refreshed so the gossip line below
1426+ -- only fires on a refresh, not on every poll. Without this, every
1427+ -- poll gossips to all seed peers via gossipTargets [], creating
1428+ -- O(clients × seeds) federation traffic per poll cycle.
1429+ yield (Just {value: {pubkey, clampedCursor, refreshPeers, didRefresh: stale}}))
7201430 case setup of
7211431 Nothing {} -> yield (Err {error: {status: 401, message: "unauthorized"}})
7221432 Just {value: s} -> do
723− when federationEnabled (fork (sendBatchedGossip s.refreshPeers [GossipOnline {s.pubkey}]))
1433+ -- S3: Only gossip when we actually refreshed presence — not on every poll.
1434+ when (federationEnabled && s.didRefresh) (fork (sendBatchedGossip s.refreshPeers [{eventType: "online", pubkey: Just {value: s.pubkey}}]))
7241435 pollResult <- doPoll s t
7251436 yield (Ok {value: pollResult})
7261437
@@ -733,12 +1444,20 @@ handlePoll = \cursor authorization clientIp -> do
7331444-- the wait transaction reads `*messages` and `retry`s until a row matching
7341445-- this pubkey/cursor is committed; the timer wins on idle. Either way we
7351446-- re-collect at the new wall time, so a timeout returns an empty page.
736−doPoll : {clampedCursor: Timestamp, pubkey: PubkeyHex | a} -> Timestamp -> IO {r *messages, clock} {cursor: Timestamp, events: [{encryptedBlob: BlobHex}]}
1447+
1448+-- Messages addressed to `pk`, unexpired at `tnow`, and newer than `cursor`. Shared
1449+-- by `doPoll`'s collect and STM-wait arms so the two predicates cannot drift: a
1450+-- wait that accepted a row the collect then filtered out (say, an expired row still
1451+-- above the cursor) would wake the poll, return an empty page with an unchanged
1452+-- cursor, and spin the client through an immediate re-poll into the same wakeup.
1453+freshFor : PubkeyHex -> Timestamp -> Timestamp -> [Message] -> [Message]
1454+freshFor = \pk cursor tnow msgs -> filter (\m -> m.toKey == pk && tnow - m.receivedAt < messageMaxAge && m.receivedAt > cursor) msgs
1455+
1456+doPoll : {clampedCursor: Timestamp, pubkey: PubkeyHex | a} -> Timestamp -> IO {r *messages, clock} {cursor: Timestamp, events: [PollEvent]}
7371457doPoll = \s t -> do
7381458 let collectAt = \tnow -> atomic (do
7391459 msgs <- *messages
740− let fresh = filter (\m -> m.toKey == s.pubkey && tnow - m.receivedAt < messageMaxAge && m.receivedAt > s.clampedCursor) msgs
741− let limited = take maxPollEvents (sortBy (\m -> m.receivedAt) fresh)
1460+ let limited = take maxPollEvents (sortBy (\m -> m.receivedAt) (freshFor s.pubkey s.clampedCursor tnow msgs))
7421461 yield {
7431462 events: map (\m -> {m.encryptedBlob}) limited,
7441463 cursor: fold (\acc m -> max acc m.receivedAt) s.clampedCursor limited
@@ -752,11 +1471,10 @@ doPoll = \s t -> do
7521471 -- the timer gets a no-op `*messages` read.
7531472 race
7541473 (do
755− _ <- now
1474+ tw <- now
7561475 atomic (do
7571476 msgs <- *messages
758− let fresh = filter (\m -> m.toKey == s.pubkey && m.receivedAt > s.clampedCursor) msgs
759− if count fresh > 0 then yield {} else retry))
1477+ if count (freshFor s.pubkey s.clampedCursor tw msgs) > 0 then yield {} else retry))
7601478 (do
7611479 atomic (do _ <- *messages; yield {})
7621480 sleep pollTimeoutMs)
@@ -765,15 +1483,21 @@ doPoll = \s t -> do
7651483
7661484-- Treat the supplied cursor as an ack: drop all messages with
7671485-- receivedAt <= cursor, plus their pending forwards.
768−ackPolledMessages : PubkeyHex -> Int -> IO {rw *forwards, rw *messages} {}
769−ackPolledMessages = \pubkey cursor -> do
1486+--
1487+-- The ack is destructive and keyed on `pubkey` alone — no session identity enters
1488+-- into it, so two sessions sharing one keypair race over one mailbox. Accepted
1489+-- limitation; see `maxSessionsPerKey`.
1490+-- Self-atomic: touches two refs (*messages and *forwards), so wraps its own
1491+-- transaction rather than relying on the caller's.
1492+ackPolledMessages : PubkeyHex -> Timestamp -> IO {rw *forwards, rw *messages} {}
1493+ackPolledMessages = \pubkey cursor -> atomic (do
7701494 msgs <- *messages
7711495 let matches = \m -> m.toKey == pubkey && m.receivedAt <= cursor
7721496 let toAck = filter matches msgs
7731497 let blobs = map (\m -> m.encryptedBlob) toAck
7741498 *messages = filter (\m -> not matches m) msgs
7751499 fwds <- *forwards
776− *forwards = filter (\f -> not (f.toKey == pubkey && elem f.encryptedBlob blobs)) fwds
1500+ *forwards = filter (\f -> not (f.toKey == pubkey && elem f.encryptedBlob blobs)) fwds)
7771501
7781502-- ============================================
7791503-- Handler: send
@@ -783,8 +1507,25 @@ ackPolledMessages = \pubkey cursor -> do
7831507-- either succeeds (every message enqueued) or fails with a non-2xx HTTP
7841508-- status carrying the rejection code ("self_send" | ...).
7851509-- Duplicates are silently treated as success so retries are idempotent.
786−-- Per-session send rate is enforced by the route's `rateLimit` clause.
1510+-- Per-client-IP send rate is enforced by the route's `rateLimit` clause.
7871511-- Federated forwards are kicked off in background forks after the atomic.
1512+-- The sender's UI treats every success the same and learns "they got it" only via
1513+-- the recipient's E2E ack.
1514+
1515+-- Batch validation: checks batch size, per-message field validity, and self-send.
1516+-- Refined-type validation at the route boundary only fires for top-level scalar
1517+-- body fields; `to`/`encryptedBlob` are nested inside the `messages` list, so the
1518+-- runtime does not check them. `isValidBlobHex` is the same predicate `BlobHex`
1519+-- refines on, so this check and the route boundary cannot drift apart.
1520+validateBatch : [{to: PubkeyHex, encryptedBlob: BlobHex | a}] -> PubkeyHex -> Maybe HttpError
1521+validateBatch = \messages senderKey ->
1522+ if count messages > maxBatchSize
1523+ then Just {value: {status: 413, message: "batch_too_large"}}
1524+ else if any (\m -> not (isValidExactHex 64 m.to && isValidBlobHex m.encryptedBlob)) messages
1525+ then Just {value: {status: 400, message: "invalid_message"}}
1526+ else if any (\m -> m.to == senderKey) messages
1527+ then Just {value: {status: 400, message: "self_send"}}
1528+ else Nothing {}
7881529
7891530-- POST /messages: send a list of ciphertexts atomically. Self-send is
7901531-- rejected up-front; the remaining batch is committed inside one atomic
@@ -796,25 +1537,44 @@ handleSendMessages = \messages authorization clientIp -> do
7961537 auth <- authedPubkey authorization t clientIp
7971538 case auth of
7981539 Nothing {} -> yield (Err {error: {status: 401, message: "unauthorized"}})
799− Just {value: senderKey} -> do
800− if count messages > maxBatchSize
801− then yield (Err {error: {status: 413, message: "batch_too_large"}})
802− else if any (\m -> m.to == senderKey) messages
803− then yield (Err {error: {status: 400, message: "self_send"}})
804− else do
1540+ Just {value: senderKey} -> case validateBatch messages senderKey of
1541+ Just {value: err} -> yield (Err {error: err})
1542+ Nothing {} -> do
8051543 committed <- atomic (do
8061544 pres <- *presence
1545+ -- S2: cap the immediate fan-out at `maxForwardsPerServer` servers per
1546+ -- recipient. The post-commit `forkForwardsTo` fires against this list
1547+ -- directly (bypassing the `*forwards` queue and its per-peer cap), so
1548+ -- without a bound here a recipient gossiped as present on thousands of
1549+ -- servers would spawn one immediate HTTP forward per server per message.
8071550 let staged = map (\m -> {
8081551 m.to,
8091552 m.encryptedBlob,
810− remoteServers: collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server)
1553+ remoteServers: take maxRemoteServersPerMessage (collectPeers (filter (\p -> p.pubkey == m.to && p.expiresAt > t) pres) (\p -> p.server))
8111554 }) messages
8121555 forEach staged (\c -> do
1556+ -- Read `*messages` *before* the append: `delivered` below must see the
1557+ -- pre-existing row, and re-reading each iteration is also what lets
1558+ -- `appendMessage`'s own dedupe collapse a blob repeated within one batch.
1559+ existingMsgs <- *messages
8131560 appendMessage c.to c.encryptedBlob t
1561+ -- Peers that already hold this blob — from an earlier send of the
1562+ -- identical ciphertext that was forwarded then pruned from
1563+ -- `*forwards`. A brand-new message has empty `deliveredTo`, so this
1564+ -- is `[]` on the normal path; on a client resend it carries the real
1565+ -- set. Mirrors `forwardQueuedToServer`'s guard so we don't re-stage a
1566+ -- forward to a peer that already accepted this blob.
1567+ let delivered = case findFirst existingMsgs (\m -> m.toKey == c.to && m.encryptedBlob == c.encryptedBlob) of
1568+ Just {value: m} -> map (\d -> d.server) m.deliveredTo
1569+ Nothing {} -> ([] : [Text])
1570+ -- Stage outbox rows for peers under their `maxForwardsPerServer`
1571+ -- ceiling. Peers at the cap still get the immediate post-commit
1572+ -- delivery attempt (`forkForwardsTo`) — only the retry-queue row
1573+ -- is skipped.
8141574 when federationEnabled (do
8151575 fwds <- *forwards
816− let newFwds = map (\s -> {toServer: s, toKey: c.to, c.encryptedBlob, retries: 0}) (filter (\s -> not any (forwardMatches c.encryptedBlob s) fwds) c.remoteServers)
817− *forwards = union fwds newFwds))
1576+ let newFwds = map (\s -> {toServer: s, toKey: c.to, c.encryptedBlob, retries: 0}) (filter (\s -> not (any (forwardMatches c.to c.encryptedBlob s) fwds) && not elem s delivered && countWhere (\f -> f.toServer == s) fwds < maxForwardsPerServer) c.remoteServers)
1577+ *forwards = capForwards (union fwds newFwds)))
8181578 yield staged)
8191579 when federationEnabled (forEach committed (\c -> fork (forkForwardsTo c.to c.encryptedBlob c.remoteServers)))
8201580 yield (Ok {value: {}})
@@ -829,21 +1589,23 @@ handleSendMessages = \messages authorization clientIp -> do
8291589-- the message), and `deliveredTo` prevents re-forwards to peers that already
8301590-- have the message. On failure the forwards row stays so
8311591-- `retryPendingForwards` can re-attempt with backoff.
1592+--
1593+-- A 404 is not a peer failure — see `retryForwardsTo` for why `no_presence` must
1594+-- not reach `recordFailure`. Here the row is left queued for the retry sweep and
1595+-- the peer's backoff (shared with gossip) is not touched.
8321596forkForwardsTo : PubkeyHex -> BlobHex -> [Text] -> IO {rw *forwardFailures, rw *forwards, rw *messages, network, clock} {}
833−forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> do
1597+forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (not isBadServerName server) (do
1598+ t <- now
8341599 result <- fetch (federationUrl server) (ForwardMessage {toKey: to, encryptedBlob})
835− let same = forwardMatches encryptedBlob server
1600+ let same = forwardMatches to encryptedBlob server
8361601 case result of
837− Err _ -> recordFailure server
1602+ Err {error: e} -> when (e.status != 404) (recordFailure server t)
8381603 Ok _ -> do
839− clearFailure server
1604+ clearFailure server t
8401605 atomic (do
8411606 fwds <- *forwards
8421607 *forwards = filter (\x -> not same x) fwds
843− msgs <- *messages
844− *messages = map (\m -> if m.toKey == to && m.encryptedBlob == encryptedBlob && not elem server m.deliveredTo
845− then {m.toKey, m.encryptedBlob, m.receivedAt, deliveredTo: union m.deliveredTo [server]}
846− else m) msgs))
1608+ markDelivered to encryptedBlob server)))
8471609
8481610-- ============================================
8491611-- Handler: federation receive
@@ -853,34 +1615,120 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> do
8531615-- currently-online local keys. Both handlers refuse with 403 when
8541616-- `federationEnabled` is off, matching the outbound paths — a single-node
8551617-- deployment accepts no peer writes to `*presence`/`*messages`.
1618+--
1619+-- SECURITY — THE OPEN-FEDERATION THREAT MODEL (accepted v0.1 limitations; the
1620+-- canonical statement, referenced by the handlers below and by
1621+-- `forwardQueuedToServer`). Neither endpoint authenticates its caller and nothing
1622+-- ties an event to the key it speaks for, so any host that can reach us may:
1623+--
1624+-- * announce an arbitrary victim pubkey as `online` at itself. We write that
1625+-- presence row, and the next `forwardQueuedToServer` hands the victim's queued
1626+-- ciphertext to the attacker's host — a *gossip redirect*.
1627+-- * append an arbitrary blob to any currently-online local key's mailbox, which
1628+-- the recipient then polls down — *unauthenticated mailbox injection*.
1629+--
1630+-- Neither breaks confidentiality or allows forgery. Payloads are sealed to the
1631+-- recipient's key and the sender's identity and signature live *inside* the AEAD
1632+-- (PROTOCOL.md §5), so a redirected blob cannot be read and an injected blob that
1633+-- does not open — or opens without a valid inner signature — is dropped by the
1634+-- client and can never impersonate a contact. What the attacker gets is metadata a
1635+-- relay is otherwise never supposed to concentrate (which keys receive traffic, how
1636+-- much, when), unsolicited storage and bandwidth, and — because our copy is pruned
1637+-- once marked `deliveredTo` — the ability to *withhold* delivery by steering a
1638+-- ciphertext at a host that will never serve it. Confidentiality-preserving;
1639+-- availability- and metadata-affecting.
1640+--
1641+-- Bounded, not prevented, by: the SSRF deny-list (`isBadServerName`), the
1642+-- `no_presence` gate, per-IP rate limits, the presence caps (`maxPresence` /
1643+-- `maxPresencePerServer`), the gossip TTL (`onlineGossipTtl`) capping how long one
1644+-- forged row survives, `maxBlobLen`, the `appendMessage` dedupe, and
1645+-- `messageMaxAge` pruning.
1646+--
1647+-- The fix for both is peer authentication: require a gossip event to carry a
1648+-- signature from the pubkey it announces, and/or authenticate the peer relay itself
1649+-- so an unknown host cannot write to `*presence` / `*messages` at all. Deferred past
1650+-- v0.1 because it needs a key-to-home-server binding the protocol does not yet
1651+-- define. PROTOCOL.md §10 enumerates what open federation does and does not protect.
8561652
8571653-- POST /federation/gossip: ingest peer presence events. The transport-level
8581654-- `fromServer` is the home server for every event. Drops every (pubkey,
8591655-- fromServer) row touched by this batch, then re-adds fresh-TTL rows for
860−-- pubkeys with a `GossipOnline` event (so Online wins on collision). For keys
1656+-- pubkeys with an `online` event (so Online wins on collision). For keys
8611657-- that transition offline → online, flushes any locally-buffered messages via
862−-- `forwardQueuedToServer`.
863−handleRecvGossip : [GossipEventType] -> Text -> IO {r *messages, rw *forwards, rw *presence, clock} (Result HttpError {})
1658+-- `forwardQueuedToAll`. Events with an unrecognized `eventType` or no
1659+-- `pubkey` are skipped (forward-compat) rather than rejecting the batch.
1660+--
1661+-- SECURITY: this is the gossip-redirect ingress described in the section header
1662+-- above — a forged `online` row written here is what steals a victim's routing.
1663+-- The presence caps applied below are the only thing bounding how much of
1664+-- `*presence` an unauthenticated peer can own.
1665+handleRecvGossip : [GossipEvent] -> ServerName -> IO {r *messages, rw *forwards, rw *presence, clock} (Result HttpError {})
8641666handleRecvGossip = \events fromServer -> do
8651667 t <- now
1668+ -- Widen the peer-supplied `ServerName` once (see `type ServerName`);
1669+ -- `forwardQueuedToAll` below still takes the refined name.
1670+ let from = (fromServer : Text)
8661671 if not federationEnabled
8671672 then yield (Err {error: {status: 403, message: "federation_disabled"}})
868− else if isLocalServer fromServer || isBadServerName fromServer || count events > maxGossipEvents
1673+ else if isLocalServer from || isBadServerName from || count events > maxGossipEvents
8691674 then yield (Err {error: {status: 400, message: "invalid_request"}})
8701675 else do
8711676 newlyOnline <- atomic (do
8721677 pres <- *presence
873− let collected = fold (\acc e -> case e of
874− GossipOnline {pubkey} -> {
875− touched: union acc.touched [pubkey],
876− added: union acc.added [{pubkey, server: fromServer, expiresAt: t + onlineGossipTtl}]
877− }
878− GossipOffline {pubkey} -> {acc.added, touched: union acc.touched [pubkey]}
879− _ -> acc) {touched: [], added: []} events
880− let kept = filter (\p -> p.server != fromServer || not elem p.pubkey collected.touched) pres
881− *presence = union kept collected.added
882− yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == fromServer && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
883− fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))
1678+ let collected = fold (\acc e -> case e.pubkey of
1679+ -- `pubkey` is nested inside the `events` list, so the route-boundary
1680+ -- refinement never validates it (the same nested-list gap that
1681+ -- `/messages` works around by hand). `refine` re-checks the `PubkeyHex`
1682+ -- predicate here, dropping a malformed key so a peer can't poison
1683+ -- `*presence` with non-hex rows that then spawn bogus-`toKey` forward
1684+ -- entries. It also yields the `PubkeyHex` the `PresenceEntry` row below
1685+ -- requires — an explicit `isValidExactHex 64` guard would check the same
1686+ -- predicate but leave the key typed `Text`.
1687+ Just {value: raw} -> case ((refine raw) : Result RefinementError PubkeyHex) of
1688+ Err _ -> acc
1689+ Ok {value: pk} -> if e.eventType == "online"
1690+ then {
1691+ touched: union acc.touched [pk],
1692+ added: union acc.added [{pubkey: pk, server: from, expiresAt: t + onlineGossipTtl}],
1693+ offline: acc.offline
1694+ }
1695+ else if e.eventType == "offline"
1696+ then {acc.added, touched: union acc.touched [pk], offline: union acc.offline [pk]}
1697+ else acc
1698+ Nothing {} -> acc) {touched: ([] : [PubkeyHex]), added: ([] : [PresenceEntry]), offline: ([] : [PubkeyHex])} events
1699+ let kept = filter (\p -> p.server != from || not elem p.pubkey collected.touched) pres
1700+ let merged = union kept collected.added
1701+ -- Cap the gossip-learned rows: per-peer first, so one peer cannot crowd every
1702+ -- other peer's routing information out of the pool, then globally. Local rows
1703+ -- are held out of both caps and re-unioned afterwards — folding them into the
1704+ -- same LRU would let a gossip flood evict our own online users, because their
1705+ -- `sessionExpiry` (60 min) is *sooner* than a gossiped row's `onlineGossipTtl`
1706+ -- (90 min) and `capList` drops soonest-to-expire first. See `maxPresence`.
1707+ let localRows = filter (\p -> isLocalServer p.server) merged
1708+ let remoteRows = filter (\p -> not isLocalServer p.server) merged
1709+ *presence = union localRows (capList (capPerServer remoteRows from maxPresencePerServer) maxPresence)
1710+ -- S8: Make online+offline for the same key mutually exclusive within one
1711+ -- batch. If a batch carries both events for a key, online wins — the presence
1712+ -- merge above already added the online row — so that key must NOT also travel
1713+ -- the offline path and cancel its own freshly-staged forwards.
1714+ let onlineKeys = map (\p -> p.pubkey) collected.added
1715+ let offlineOnly = filter (\pk -> not elem pk onlineKeys) collected.offline
1716+ -- N14: Cancel pending forwards to `from` for offline keys. Without this,
1717+ -- forwards to a peer that just went offline retry for up to 80 minutes,
1718+ -- wasting maxForwardsPerServer slots and generating failed HTTP requests.
1719+ -- The message stays in *messages, so it's re-forwarded if the key
1720+ -- re-appears on another peer.
1721+ when (count offlineOnly > 0) (do
1722+ fwds <- *forwards
1723+ *forwards = filter (\f -> not (f.toServer == from && elem f.toKey offlineOnly)) fwds)
1724+ yield (filter (\pk -> not any (\p -> p.pubkey == pk && p.server == from && p.expiresAt > t) pres) (map (\p -> p.pubkey) collected.added)))
1725+ -- S4: forward queued mail for ALL newly-online keys in one mailbox pass. The
1726+ -- old per-key cap (maxNewlyOnlineForwards, N13) bounded amplification but
1727+ -- silently deferred delivery past the 10th key, and a re-announce within the
1728+ -- TTL never recovered it (the deliveredTo/presence state made the key look
1729+ -- already-handled). `forwardQueuedToAll` bounds the cost to O(|messages|)
1730+ -- regardless of how many keys came online.
1731+ fork (forwardQueuedToAll newlyOnline fromServer)
8841732 yield (Ok {value: {}})
8851733
8861734-- POST /federation/forward: receive a single federated message destined for
@@ -890,8 +1738,13 @@ handleRecvGossip = \events fromServer -> do
8901738-- actively-polling client and get stuck. If B refuses, A retries via
8911739-- `retryPendingForwards` against the next presence target.
8921740-- Re-delivery of an already-stored (toKey, blob) pair is treated as success
893−-- without a second append, so a sender whose success response was lost can
894−-- retry without duplicating the message in the recipient's mailbox.
1741+-- without a second append (`appendMessage` is idempotent), so a sender whose
1742+-- success response was lost can retry without duplicating the message.
1743+--
1744+-- SECURITY: this is the unauthenticated-mailbox-injection ingress described in the
1745+-- section header above — an unsolicited row lands here. The `no_presence` gate is
1746+-- what keeps the surface to keys that are already online, so an attacker must first
1747+-- observe or force presence.
8951748handleForwardMessage : PubkeyHex -> BlobHex -> IO {rw *messages, r *presence, rw *seqCounter, clock} (Result HttpError {})
8961749handleForwardMessage = \toKey encryptedBlob -> do
8971750 t <- now
@@ -902,8 +1755,7 @@ handleForwardMessage = \toKey encryptedBlob -> do
9021755 if not hasLocalPresence pres toKey t
9031756 then yield (Err {error: {status: 404, message: "no_presence"}})
9041757 else do
905− msgs <- *messages
906− when (not any (\m -> m.toKey == toKey && m.encryptedBlob == encryptedBlob) msgs) (appendMessage toKey encryptedBlob t)
1758+ appendMessage toKey encryptedBlob t
9071759 yield (Ok {value: {}}))
9081760
9091761-- ============================================
@@ -934,13 +1786,24 @@ api = serve Api where
9341786-- Endless tick: prune expired state and gossip offline for locally-expired
9351787-- keys. First sweep happens immediately so stale rows persisted by the
9361788-- previous process clear before the next interval elapses.
937−backgroundPrune : IO {rw *challenges, rw *forwardFailures, rw *messages, rw *presence, rw *sessions, network, clock} a
1789+backgroundPrune : IO {rw *challenges, rw *forwardFailures, rw *forwards, rw *messages, rw *presence, rw *sessions, network, clock} a
9381790backgroundPrune = do
9391791 t <- now
1792+ -- Snapshot the peer set *before* the sweep. `pruneExpired` drops every expired
1793+ -- presence row, and a peer is only known to us through the rows it announced —
1794+ -- so a peer whose keys all expired in this same sweep vanishes from `*presence`
1795+ -- at the very moment we need to tell it that our keys went offline. Reading
1796+ -- peers only after the prune (as this used to) silently skipped exactly those
1797+ -- peers, which then held a stale `online` row for our users until their own
1798+ -- `onlineGossipTtl` lapsed — up to 90 minutes of mail routed at a relay the
1799+ -- user is no longer on. Union the pre- and post-sweep sets so both the peers
1800+ -- that survived and the peers that expired out get the offline event.
1801+ prePres <- *presence
1802+ let prePeers = collectPeers prePres (\p -> p.server)
9401803 expiredLocal <- pruneExpired t
9411804 when federationEnabled (fork (do
9421805 pres <- *presence
943− sendBatchedGossip (collectPeers pres (\p -> p.server)) (map (\pk -> GossipOffline {pubkey: pk}) expiredLocal)))
1806+ sendBatchedGossip (union prePeers (collectPeers pres (\p -> p.server))) (map (\pk -> {eventType: "offline", pubkey: Just {value: pk}}) expiredLocal)))
9441807 sleep backgroundPruneInterval
9451808 backgroundPrune
9461809
@@ -961,10 +1824,25 @@ backgroundRetryForwards = do
9611824-- Fork the background prune loop (which runs an immediate sweep on its
9621825-- first tick) and start the HTTP listener. The forward-retry loop only
9631826-- runs when federation is enabled.
964−main : IO _ {}
9651827main = do
9661828 logInfo ("Skrepka relay server starting on " ++ bindHost ++ ":" ++ show listenPort ++ " ...")
9671829 logInfo (" federation: " ++ (if federationEnabled then "enabled" else "disabled"))
1830+ -- The warning three comments have promised since `serverHost`: a host that
1831+ -- fails `isValidServerName` cannot be refined into the `fromServer` a gossip
1832+ -- body requires, so `sendBatchedGossip` silently no-ops and this relay never
1833+ -- announces presence to anyone. Say so at startup rather than leaving the
1834+ -- operator to discover a federation-shaped hole by its absence.
1835+ case localServerName of
1836+ Nothing {} -> logWarn ("serverHost '" ++ serverHost ++ "' is not a valid ServerName (must be lowercase, no trailing dot, at most " ++ show maxServerNameLen ++ " chars); federation egress disabled")
1837+ _ -> yield {}
1838+ -- /auth/verify checks the client's host-bound signature against
1839+ -- `authHost serverHost`, so a relay still on the compiled-in default 401s every
1840+ -- login the moment a client dials it under any other name — which is exactly the
1841+ -- install.sh deployment (Caddy fronts a public DOMAIN). `--serverHost=<domain>` is
1842+ -- accepted and then IGNORED by the current Knot runtime, so it must be baked in at
1843+ -- build time instead. Warn loudly rather than let every login fail silently.
1844+ 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>")
1845+ when (federationEnabled && count seedPeerList > 0) (logInfo (" seed peers: " ++ show seedPeerList))
9681846 fork backgroundPrune
9691847 when federationEnabled (fork backgroundRetryForwards)
9701848 listenOn bindHost listenPort api
server.onew file

Binary file not shown.

skrepka.service+9−1
@@ -5,7 +5,15 @@ Wants=network-online.target
55
66[Service]
77Type=simple
8−ExecStart=/usr/local/bin/skrepka-server
8+# EDIT --serverHost: replace relay.example.com with the public hostname clients
9+# use to reach this relay. It is the federation identity and the hostname a
10+# client binds its auth signature to ("skrepka-auth-v1:<host>:<challenge>"); the
11+# built-in default is "localhost", so leaving it unset makes /auth/verify reject
12+# every signature.
13+# --http-max-body-bytes must exceed maxBlobLen (40 MiB hex) plus the JSON
14+# envelope, otherwise the runtime's default 16 MiB body cap rejects a max-size
15+# blob with 413 before the handler's BlobHex check is ever reached.
16+ExecStart=/usr/local/bin/skrepka-server --serverHost=relay.example.com --http-max-body-bytes=42M
917Restart=on-failure
1018RestartSec=5
1119