on master help sign in
Audit: implement all 18 findings from protocol/codebase review

Code fixes: - #2: Per-recipient mailbox cap (maxMessagesPerRecipient=10000) with LRU eviction in appendMessage (server.knot) - #3: Only ack messages actually stored, not duplicate ids (app.rs) - #4: Handle HTTP 413 by halving batch budget instead of blind retry; single-item 413 falls through to retry budget to avoid infinite spin (app.rs) - #11: Respect Retry-After header on 429, clamped to 5 min max; don't charge retry budget for rate-limiting (app.rs) - #12: Remove unused compose field from ViewModel (model.rs, app.rs) - #13: Add iOS background fetch via BGAppRefreshTask with isProtectedDataAvailable guard; Core made singleton (Swift shell) - #14: Log session pool evictions in handleVerifyAuth (server.knot) - #15: Profile staleness check uses strict > instead of >= to prevent same-timestamp replays (app.rs) - #17: Global forward cap (maxForwardsTotal=50000) with capForwards helper, evicts highest-retry entries first (server.knot) - #18: Guard against self-sender in ingest_poll; add test (app.rs) - Bonus: Fix parse_kv to distinguish read errors from absent keys (Result<Option<T>, ()>); prevents background-fetch-while-locked from destroying contacts/history with empty defaults (app.rs) Spec edits (PROTOCOL.md): - #5: Document payload size limits in §4 (body, id, display_name, bio, photo, ack_ids caps) - #6: Clarify version byte rejection: MUST reject without further processing - #7: Define "ignored" as silently dropped, no error to sender Blocked findings documented in PROTOCOL-REVIEW.md: - #1: now_ms() impurity (needs crux_time refactor) - #8: Token timing side-channel (needs constant-time compare in Knot) - #9: Open federation attacks (needs peer authentication) - #10: SSRF lexical only (needs resolve-then-pin in fetch) - #16: Federation retry starvation (needs fetch deadline support) Tests: 99 -> 106 (7 new). Clippy clean.

commitf04d81b6611c0403e79d2dbbfcc19ae9ded6929f authoriko <mail@iko.soy> dateMon 13 Jul 2026, 03:22 (3 months ago) parentc28cf14
8 files changed+733−65
PROTOCOL-REVIEW.md+62 PROTOCOL.md+17−2 apple/SkrepkaApp/Core.swift+10−1 apple/SkrepkaApp/SkrepkaApp.swift+147−6 apple/project.yml+8 core/src/app.rs+409−47 core/src/model.rs−1 server.knot+80−8
PROTOCOL-REVIEW.md+62
@@ -218,3 +218,65 @@ Both need runtime support the current `fetch` does not expose.
218218 document-as-accepted or add peer authentication.
2192194. **#9** is a design tradeoff; document as accepted until `fetch` supports a
220220 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.
PROTOCOL.md+17−2
@@ -191,7 +191,7 @@ A blob is rounded up to the smallest bucket ≥ its unpadded size. Above 65536 b
191191
192192### Cryptographic Versioning
193193
194−The wire format carries a leading **version byte** (currently `0x01`) on every encrypted blob. A recipient that sees a version it does not recognise rejects the blob (`CryptoError::Decrypt`) without attempting further processing, so a future revision can introduce a new AEAD, KDF, or curve by bumping this byte. Version 0x01 includes length padding (see *Length Padding* above): blobs are padded to fixed-size bucket boundaries, and the inner buffer carries a 4-byte `compressed_len` field so the recipient can separate the zstd frame from trailing padding. The HKDF `info` string remains the fixed constant `"skrepka-v1"` for version 0x01. 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.
194+The wire format carries a leading **version byte** (currently `0x01`) 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 0x01 includes length padding (see *Length Padding* above): blobs are padded to fixed-size bucket boundaries, and the inner buffer carries a 4-byte `compressed_len` field so the recipient can separate the zstd frame from trailing padding. The HKDF `info` string remains the fixed constant `"skrepka-v1"` for version 0x01. 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.
195195
196196---
197197
@@ -229,7 +229,7 @@ After decryption (and zstd decompression), the plaintext is a UTF-8 JSON object
229229
230230- `type` and `ts` are present on every payload. `ts` is the sender's wall-clock at send time, in **milliseconds** since the Unix epoch.
231231- `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`.
232−- Clients MUST silently ignore message types they do not recognize. This allows newer clients to introduce new types without breaking older ones.
232+- 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.
233233
234234### Message Types
235235
@@ -243,6 +243,21 @@ The current implementation defines three plaintext message types:
243243
244244Future message types (media attachments, read receipts, group messages) are not part of this revision.
245245
246+### Payload Size Limits
247+
248+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.
249+
250+| Field | Cap | Rationale |
251+|-------------------|-------------|--------------------------------------------------------|
252+| `text.body` | 64 KiB | A text body is never legitimately this long. |
253+| `text.id` | 128 chars | IDs are UUIDs; 128 chars is generous. |
254+| `profile.display_name` | 128 chars (Unicode) | A display name is not a bio. |
255+| `profile.bio` | 1024 chars (Unicode) | Enough for a paragraph. |
256+| `profile.photo` | 64 KiB (base64) | A 256px JPEG is ~10-30 KiB; 64 KiB is headroom. |
257+| `delivery.ack.ack_ids` | 1024 entries | Bounds the O(n·m) scan a peer can dictate. |
258+
259+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.
260+
246261### Profiles
247262
248263Users 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.
apple/SkrepkaApp/Core.swift+10−1
@@ -6,6 +6,15 @@ import SkrepkaShared
66/// each effect (HTTP, key-value, timer, render) and resolves it back into the core.
77@MainActor
88final 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+
918 @Published var view: ViewModel
1019
1120 /// A Keychain failure never reaches the core — without an identity there is no
@@ -16,7 +25,7 @@ final class Core: ObservableObject {
1625 private let core = CoreFFI()
1726 private let store = KvStore()
1827
19− init() {
28+ private init() {
2029 // swiftlint:disable:next force_try
2130 self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
2231 bootIdentity()
apple/SkrepkaApp/SkrepkaApp.swift+147−6
@@ -1,20 +1,161 @@
1+import BackgroundTasks
12import SwiftUI
3+import UIKit
24
35@main
46struct SkrepkaApp: App {
5− @StateObject private var core = Core()
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
613 @Environment(\.scenePhase) private var scenePhase
714
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+
831 var body: some Scene {
932 WindowGroup {
1033 RootView(core: core)
1134 .onChange(of: scenePhase) { _, phase in
12− // Backgrounding kills the long-poll socket and the app stops running,
13− // so on return the core would otherwise sit out its whole backoff
14− // (up to 30s) before noticing. Reconnect immediately instead.
15− guard phase == .active, core.view.hasIdentity else { return }
16− core.update(.connect)
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+ }
1750 }
1851 }
1952 }
2053}
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. Completing
91+ // from both would trap ("task was already completed"), and the window is real —
92+ // the system can expire us well before the nominal budget.
93+ task.expirationHandler = { work.cancel() }
94+ }
95+
96+ private static func run(_ task: BGAppRefreshTask) async {
97+ // Before anything that can fail or be cut short: we still want the next pass.
98+ schedule()
99+
100+ // Every kv file is written `completeUnlessOpen`, so while the device is locked an
101+ // *existing* one cannot be opened and every read fails. The core cannot tell a failed
102+ // read from an absent key — `parse_kv` collapses both to `None` — so it would come up
103+ // with no contacts and no history, ingest the poll into that emptiness, and persist
104+ // the result back over the real files. Nothing downstream stops it: the Keychain item
105+ // is `AfterFirstUnlock`, so the identity loads and the poll succeeds. Locked means we
106+ // must not run at all.
107+ guard UIApplication.shared.isProtectedDataAvailable else {
108+ task.setTaskCompleted(success: false)
109+ return
110+ }
111+
112+ let deadline = Date(timeIntervalSinceNow: budget)
113+ let core = Core.shared
114+
115+ // Reading `Core.shared` is what boots the core when the system launched us straight
116+ // into the background. Startup is asynchronous — the identity, then five kv loads —
117+ // and the core fires its own `Connect` once the settings land. It must be left to do
118+ // that: nudging it beforehand would authenticate against the *default* relay, because
119+ // the configured URL has not been read off disk yet.
120+ guard await settle(by: deadline, until: { core.view.hasIdentity }) else {
121+ task.setTaskCompleted(success: false)
122+ return
123+ }
124+
125+ // Still offline once startup has had its chance: either the core is idle, or it was
126+ // suspended part-way through a backoff whose timer will not come due for another 30s.
127+ // A `Connect` collapses that wait. (It is a no-op when the core already believes it
128+ // is online — there, a stale poll is the watchdog's problem, not ours.)
129+ let startupDeadline = min(deadline, Date(timeIntervalSinceNow: 3))
130+ let connecting = await settle(by: startupDeadline, until: {
131+ core.view.connStatus != "offline"
132+ })
133+ if !connecting {
134+ core.update(.connect)
135+ }
136+
137+ // Online is the token in hand and the poll issued. Anything the relay was holding
138+ // comes back on that first page.
139+ guard await settle(by: deadline, until: { core.view.connStatus == "online" }) else {
140+ task.setTaskCompleted(success: false)
141+ return
142+ }
143+ try? await Task.sleep(for: .seconds(idleGrace))
144+
145+ task.setTaskCompleted(success: !Task.isCancelled)
146+ }
147+
148+ /// Waits for a condition on the view, or for the deadline (or expiry) to overtake it.
149+ ///
150+ /// The core has nothing to await — it publishes a `ViewModel` and that is the whole of
151+ /// its output — so the shell watches that. A 200ms tick across the handful of transitions
152+ /// this cares about is cheaper than threading a Combine subscription through an actor hop.
153+ private static func settle(by deadline: Date, until condition: () -> Bool) async -> Bool {
154+ while !condition() {
155+ guard !Task.isCancelled, Date() < deadline else { return false }
156+ // Cancellation makes this throw at once; the guard above is what breaks the loop.
157+ try? await Task.sleep(for: .milliseconds(200))
158+ }
159+ return true
160+ }
161+}
apple/project.yml+8
@@ -43,6 +43,14 @@ targets:
4343 - UIInterfaceOrientationPortrait
4444 NSCameraUsageDescription: Scan a contact's QR code to add them.
4545 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
4654 # Declared so App Store Connect stops asking on every upload.
4755 ITSAppUsesNonExemptEncryption: false
4856 # Relays must be reachable over TLS. The only cleartext exception is a
core/src/app.rs+409−47
@@ -91,6 +91,15 @@ const MAX_SEND_BATCH: usize = 100;
9191/// relay's job to reject it, and its rejection is per-message and permanent.
9292const MAX_SEND_BATCH_BYTES: usize = 4 * 1024 * 1024;
9393
94+/// Ceiling on a `Retry-After` we will honour from a relay.
95+///
96+/// The header is a number the relay chooses and we obey, so without a cap a
97+/// single `Retry-After: 999999999` — from a hostile relay, or a merely broken
98+/// one — parks the outbox for thirty years with nothing on screen to say why.
99+/// Five minutes is longer than any real rate-limit window and still recovers on
100+/// its own.
101+const MAX_RETRY_AFTER_MS: u64 = 5 * 60 * 1000;
102+
94103// kv keys
95104const K_SETTINGS: &str = "settings";
96105const K_PROFILE: &str = "profile";
@@ -245,13 +254,16 @@ const STARTUP_LOADS: u8 = 5;
245254/// The batch a live `/messages` request is carrying: the first `count` items of
246255/// the outbox, all bound for `recipient`.
247256///
248−/// `SendResult` needs both. The count is what it pops on success and what it
257+/// `SendResult` needs all three. The count is what it pops on success and what it
249258/// charges a retry against on a transient failure; the recipient is what tells
250259/// `SetBlocked` and `DeleteContact` whether the items they are about to remove
251−/// are the very ones this send is going to pop.
260+/// are the very ones this send is going to pop; and `bytes` — the size of the
261+/// body we actually built — is what a `413` halves to find a batch this relay
262+/// will accept (see `Model::send_batch_budget`).
252263struct InFlight {
253264 recipient: String,
254265 count: usize,
266+ bytes: usize,
255267}
256268
257269pub struct Model {
@@ -305,6 +317,20 @@ pub struct Model {
305317 /// it (`SetBlocked`, `DeleteContact`): there is then nothing left to pop, and
306318 /// popping by count anyway would eat whichever messages slid into their place.
307319 in_flight: Option<InFlight>,
320+ /// What this relay will actually accept as a request body, in hex characters,
321+ /// once it has told us. `None` until then — meaning "assume
322+ /// `MAX_SEND_BATCH_BYTES`", which is a guess at a cap the protocol never
323+ /// publishes.
324+ ///
325+ /// A relay configured below our guess answers `413`, and a `413` read as a
326+ /// transient failure is a batch that is rebuilt identically, rejected
327+ /// identically, and retried until every item in it has burned its retry budget
328+ /// and been dropped — the outbox drains into the void without a single message
329+ /// leaving. So a `413` *measures* the cap instead: halve what we just tried and
330+ /// rebuild smaller. It is deliberately not reset on success — the batch that
331+ /// worked is evidence about the relay, and the relay's cap does not grow — only
332+ /// on a server change, where the evidence no longer applies.
333+ send_batch_budget: Option<usize>,
308334 /// Startup kv loads still outstanding. `Connect` only fires at 0, so a poll
309335 /// can't land and be persisted against a half-loaded model — which would
310336 /// then be clobbered by the loads still in flight.
@@ -335,6 +361,7 @@ impl Default for Model {
335361 auth_gen: 0,
336362 flushing: false,
337363 in_flight: None,
364+ send_batch_budget: None,
338365 loads_pending: 0,
339366 page: Page::default(),
340367 active_peer: None,
@@ -560,52 +587,76 @@ impl App for Skrepka {
560587 }
561588
562589 Event::LoadedSettings(res) => {
563− if let Some(s) = parse_kv::<Settings>(res) {
564− // Re-validate on the way in. The URL was normalized when the
565− // user typed it, but the blob on disk is not trustworthy: an
566− // older build wrote URLs this parser rejects, and a corrupted
567− // or hand-edited file would reach `Http::post`, which unwraps
568− // the parse. That is a panic on every launch — a boot loop
569− // with no way out from inside the app.
570− model.settings.server_url = normalize_server_url(&s.server_url)
571− .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
590+ match parse_kv::<Settings>(res) {
591+ Ok(Some(s)) => {
592+ // Re-validate on the way in. The URL was normalized when the
593+ // user typed it, but the blob on disk is not trustworthy: an
594+ // older build wrote URLs this parser rejects, and a corrupted
595+ // or hand-edited file would reach `Http::post`, which unwraps
596+ // the parse. That is a panic on every launch — a boot loop
597+ // with no way out from inside the app.
598+ model.settings.server_url = normalize_server_url(&s.server_url)
599+ .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
600+ }
601+ Ok(None) => {}
602+ Err(()) => {
603+ model.error = Some("could not read settings from storage".into());
604+ }
572605 }
573606 self.startup_done(model).and(render())
574607 }
575608 Event::LoadedProfile(res) => {
576− if let Some(p) = parse_kv::<OwnProfile>(res) {
577− model.profile = p;
609+ match parse_kv::<OwnProfile>(res) {
610+ Ok(Some(p)) => model.profile = p,
611+ Ok(None) => {}
612+ Err(()) => model.error = Some("could not read profile from storage".into()),
578613 }
579614 self.startup_done(model).and(render())
580615 }
581616 Event::LoadedContacts(res) => {
582617 let mut cmds = vec![self.startup_done(model)];
583− if let Some(list) = parse_kv::<Vec<Contact>>(res) {
584− model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
585− // Lazily load each conversation's messages.
586− cmds.extend(model.contacts.keys().map(|peer| {
587− let peer = peer.clone();
588− KeyValue::get(k_messages(&peer))
589− .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
590− }));
618+ match parse_kv::<Vec<Contact>>(res) {
619+ Ok(Some(list)) => {
620+ model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
621+ // Lazily load each conversation's messages.
622+ cmds.extend(model.contacts.keys().map(|peer| {
623+ let peer = peer.clone();
624+ KeyValue::get(k_messages(&peer))
625+ .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
626+ }));
627+ }
628+ Ok(None) => {}
629+ Err(()) => model.error = Some("could not read contacts from storage".into()),
591630 }
592631 Command::all(cmds).and(render())
593632 }
594633 Event::LoadedCursor(res) => {
595− if let Some(c) = parse_kv::<i64>(res) {
596− model.cursor = c;
634+ match parse_kv::<i64>(res) {
635+ Ok(Some(c)) => model.cursor = c,
636+ Ok(None) => {}
637+ Err(()) => model.error = Some("could not read cursor from storage".into()),
597638 }
598639 self.startup_done(model).and(render())
599640 }
600641 Event::LoadedOutbox(res) => {
601− if let Some(list) = parse_kv::<Vec<OutboxItem>>(res) {
602− model.outbox = list.into();
642+ match parse_kv::<Vec<OutboxItem>>(res) {
643+ Ok(Some(list)) => model.outbox = list.into(),
644+ Ok(None) => {}
645+ Err(()) => model.error = Some("could not read outbox from storage".into()),
603646 }
604647 self.startup_done(model).and(render())
605648 }
606649 Event::LoadedMessages(peer, res) => {
607− if let Some(list) = parse_kv::<Vec<StoredMessage>>(res) {
608− model.messages.insert(peer, list);
650+ match parse_kv::<Vec<StoredMessage>>(res) {
651+ Ok(Some(list)) => {
652+ model.messages.insert(peer, list);
653+ }
654+ Ok(None) => {}
655+ Err(()) => {
656+ // Don't overwrite a potentially-loaded conversation with
657+ // an empty default — the read failed, the data is still
658+ // on disk.
659+ }
609660 }
610661 render()
611662 }
@@ -683,6 +734,12 @@ impl App for Skrepka {
683734 model.settings.server_url = url;
684735 model.token = None;
685736 model.conn = ConnStatus::Offline;
737+ // Whatever we learned about the old relay's body cap was about *it*.
738+ // A new relay is entitled to a bigger one, and carrying a shrunken
739+ // budget over would cap our batches against a limit that no longer
740+ // exists — with no `413` left to correct it, since the new relay has
741+ // no reason to send one.
742+ model.send_batch_budget = None;
686743 // The cursor is *per relay*: it is that server's own sequence
687744 // number, and the next poll acks everything up to it. Carrying
688745 // relay A's cursor (roughly `now_ms()`) over to relay B tells B
@@ -1069,6 +1126,56 @@ impl App for Skrepka {
10691126 // The batch stays exactly as it is — re-auth, then re-send it.
10701127 return Command::event(Event::Authenticate);
10711128 }
1129+ if status == 429 {
1130+ // Rate-limited. Not a delivery failure and not the items'
1131+ // fault, so they are not charged a retry — a client that spent
1132+ // its delivery budget on the relay's load would drop perfectly
1133+ // good messages for being sent too enthusiastically. They stay
1134+ // queued untouched; we just wait.
1135+ //
1136+ // `Retry-After` is the relay saying how long. Only the
1137+ // delta-seconds form is read — the HTTP-date form falls back to
1138+ // our own backoff — and it is clamped either way, because
1139+ // obeying an unbounded number from a relay is how the outbox
1140+ // gets parked forever.
1141+ let delay = resp
1142+ .header("retry-after")
1143+ .map(|v| v.last().as_str())
1144+ .and_then(|v| v.trim().parse::<u64>().ok())
1145+ .map_or_else(
1146+ || Duration::from_millis(backoff_ms(model.poll_retries)),
1147+ |secs| {
1148+ Duration::from_millis(
1149+ secs.saturating_mul(1000).min(MAX_RETRY_AFTER_MS),
1150+ )
1151+ },
1152+ );
1153+ return Time::notify_after(delay)
1154+ .0
1155+ .then_send(|_| Event::StartFlush)
1156+ .and(render());
1157+ }
1158+ if status == 413 {
1159+ // The body outgrew what this relay accepts. `MAX_SEND_BATCH_BYTES`
1160+ // is only a guess at that cap, and this relay's is smaller — so
1161+ // treating the `413` as transient rebuilds the very same batch,
1162+ // which is rejected the very same way, forever.
1163+ //
1164+ // Measure the cap instead: halve what this batch actually
1165+ // weighed and rebuild under that. The items are untouched (we
1166+ // only pop on 200/400), so there is nothing to re-queue.
1167+ //
1168+ // Only a *splittable* batch takes this path. A lone item over
1169+ // the cap cannot be made any smaller, and halving the budget
1170+ // around it would spin — `flush_next` always takes the first
1171+ // item whatever it weighs. It falls through to `retry_batch`,
1172+ // whose budget is what eventually drops it, with an error the
1173+ // user can see.
1174+ if let Some(b) = batch.as_ref().filter(|b| b.count > 1) {
1175+ model.send_batch_budget = Some(b.bytes / 2);
1176+ return self.flush_next(model);
1177+ }
1178+ }
10721179 if (200..300).contains(&status) || status == 400 {
10731180 // The batch was accepted, or rejected outright (`self_send` /
10741181 // `invalid_message` — both permanent, and the relay takes a
@@ -1182,7 +1289,6 @@ impl App for Skrepka {
11821289 active_peer_ob: hex_to_ob(&active_peer),
11831290 active_peer_blocked: active_contact.is_some_and(|c| c.blocked),
11841291 messages,
1185− compose: model.compose.clone(),
11861292 error: model.error.clone().unwrap_or_default(),
11871293 }
11881294 }
@@ -1344,6 +1450,9 @@ impl Skrepka {
13441450 let mut recipient: Option<String> = None;
13451451 let mut messages: Vec<Envelope> = Vec::new();
13461452 let mut bytes = 0usize;
1453+ // What we believe this relay's body cap to be — `MAX_SEND_BATCH_BYTES`
1454+ // until a `413` tells us otherwise.
1455+ let batch_budget = model.send_batch_budget.unwrap_or(MAX_SEND_BATCH_BYTES);
13471456
13481457 while messages.len() < MAX_SEND_BATCH {
13491458 let Some(item) = model.outbox.get(messages.len()) else {
@@ -1371,7 +1480,7 @@ impl Skrepka {
13711480 // payload must still reach the relay, which rejects it permanently
13721481 // (`400`) and so gets it out of the queue for good. Past the first,
13731482 // stop before the body outgrows what a relay will accept.
1374− if !messages.is_empty() && bytes + hex_blob.len() > MAX_SEND_BATCH_BYTES {
1483+ if !messages.is_empty() && bytes + hex_blob.len() > batch_budget {
13751484 break;
13761485 }
13771486 bytes += hex_blob.len();
@@ -1412,6 +1521,7 @@ impl Skrepka {
14121521 model.in_flight = Some(InFlight {
14131522 recipient,
14141523 count: messages.len(),
1524+ bytes,
14151525 });
14161526 let url = format!("{}/messages", model.settings.server_url);
14171527 match Http::post(url)
@@ -1524,6 +1634,17 @@ impl Skrepka {
15241634 };
15251635 let sender = dec.sender_hex;
15261636
1637+ // Mail from ourselves. A relay cannot forge this — the sender identity
1638+ // is signed inside the AEAD — but it can *echo back* a blob we sent
1639+ // through it, and one addressed to ourselves decrypts perfectly well.
1640+ // Nothing downstream would notice: it would auto-create a contact for
1641+ // our own key (which `AddContact` explicitly refuses to do), file our
1642+ // own words as an incoming message, and queue a delivery ack addressed
1643+ // to us — which flushes, comes back, and is acked again.
1644+ if sender == model.my_pubkey {
1645+ continue;
1646+ }
1647+
15271648 // Blocking cuts the peer off entirely (PROTOCOL.md §4) — not just
15281649 // their texts. A blocked peer must not be able to rewrite their
15291650 // display name and avatar in our contact list, mark our messages
@@ -1581,8 +1702,10 @@ impl Skrepka {
15811702 id_set.insert(msg_id.clone());
15821703 trim_history(convo);
15831704 touched_convos.insert(sender.clone());
1705+ // Only ack a message we actually stored — a duplicate id
1706+ // was already acked when its first copy was ingested.
1707+ ack_targets.entry(sender.clone()).or_default().push(msg_id);
15841708 }
1585− ack_targets.entry(sender).or_default().push(msg_id);
15861709 }
15871710 Payload::DeliveryAck { ack_ids } => {
15881711 // Staleness check: drop a replayed ack whose ts predates the
@@ -1636,7 +1759,15 @@ impl Skrepka {
16361759 // peer that parked it in the future would freeze its own
16371760 // profile, since every honest update it sent afterwards would
16381761 // read as a stale replay of itself.
1639− if ts >= c.last_profile_ts {
1762+ //
1763+ // Strict `>` (not `>=`): a replayed profile at the *same* `ts`
1764+ // as the one we already stored is a re-delivery, not an update.
1765+ // `>=` would let a captured blob overwrite the cached profile
1766+ // with an identical one — harmless in the normal case, but it
1767+ // also means a peer who sends `ts = 0` (the default when the
1768+ // field is missing) can overwrite another `ts = 0` profile
1769+ // indefinitely.
1770+ if ts > c.last_profile_ts {
16401771 c.display_name = display_name;
16411772 c.bio = bio;
16421773 c.photo = photo;
@@ -1723,22 +1854,26 @@ fn trim_history(convo: &mut Vec<StoredMessage>) {
17231854 }
17241855}
17251856
1726−/// Decode a kv `get` result into a typed value, ignoring errors / absence.
1857+/// Decode a kv `get` result into a typed value.
17271858///
1728−/// `None` conflates three different things: the key is absent (a fresh install),
1729−/// the read failed, and the bytes are present but don't deserialize. The caller
1730−/// keeps its default in all three, and the next mutation writes that default back
1731−/// — so a blob we merely *failed to read* is destroyed rather than left alone.
1859+/// Returns `Ok(Some(value))` when the key exists and deserializes, `Ok(None)`
1860+/// when the key is absent (a fresh install), and `Err` when the read itself
1861+/// failed (e.g. the file is locked or corrupt). The caller MUST NOT overwrite
1862+/// a key with a default when the read failed — the data is still on disk, and
1863+/// writing the default back destroys it. An absent key is safe to default.
17321864///
1733−/// The mitigation is upstream: every persisted type is `#[serde(default)]` (see
1734−/// `model.rs`), so adding a field can't make an old blob unreadable, which is by
1735−/// far the likeliest way this fires in practice. A genuinely corrupt blob is
1736−/// still lost. Fixing that properly means distinguishing the three cases here and
1737−/// refusing to overwrite on the third — a change to every caller and to the error
1738−/// surface, not a drive-by.
1739−fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Option<T> {
1740− let bytes = res.ok().flatten()?;
1741− serde_json::from_slice(&bytes).ok()
1865+/// The old `parse_kv` conflated read errors with absence, which meant a
1866+/// background wake while the device was locked (where `completeUnlessOpen`
1867+/// refuses to open existing files) would load an empty model, and the next
1868+/// `persist_*` would write that emptiness back over the real data.
1869+fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Result<Option<T>, ()> {
1870+ match res {
1871+ Ok(Some(bytes)) => serde_json::from_slice(&bytes)
1872+ .map(Some)
1873+ .map_err(|_| ()),
1874+ Ok(None) => Ok(None),
1875+ Err(_) => Err(()),
1876+ }
17421877}
17431878
17441879#[cfg(test)]
@@ -1839,6 +1974,33 @@ mod tests {
18391974 .build()
18401975 }
18411976
1977+ /// The same, plus one header — the only thing a relay can say to us beyond a
1978+ /// status code, and `Retry-After` is the one we act on.
1979+ fn response_with_header(status: u16, name: &str, value: &str) -> crux_http::Response<Vec<u8>> {
1980+ let status = crux_http::http::StatusCode::try_from(status).unwrap();
1981+ crux_http::testing::ResponseBuilder::with_status(status)
1982+ .header(name, value)
1983+ .body(Vec::new())
1984+ .build()
1985+ }
1986+
1987+ /// The delays a command actually asks the shell to wait, in milliseconds.
1988+ /// Asserting on the model alone cannot see these — a backoff lives entirely in
1989+ /// the effect.
1990+ fn scheduled_delays(cmd: &mut Command<Effect, Event>) -> Vec<u64> {
1991+ cmd.effects()
1992+ .filter_map(|e| match e {
1993+ Effect::Time(req) => match req.operation {
1994+ crux_time::TimeRequest::NotifyAfter { duration, .. } => {
1995+ Some(Duration::from(duration).as_millis() as u64)
1996+ }
1997+ _ => None,
1998+ },
1999+ _ => None,
2000+ })
2001+ .collect()
2002+ }
2003+
18422004 /// One full send round-trip: flush the outbox, then answer with `status`.
18432005 fn send_and_answer(app: &Skrepka, m: &mut Model, status: u16) {
18442006 let _ = app.update(Event::StartFlush, m);
@@ -1977,6 +2139,71 @@ mod tests {
19772139 assert!(m.token.is_none(), "but the token is thrown away");
19782140 }
19792141
2142+ /// A 429 is the relay's load, not the message's fault. Charging the batch a
2143+ /// retry for it would let a busy relay drive perfectly good messages out of the
2144+ /// outbox — and the relay usually says exactly how long to wait, so wait that
2145+ /// long rather than guessing.
2146+ #[test]
2147+ fn a_429_waits_out_retry_after_without_spending_the_retry_budget() {
2148+ let app = Skrepka;
2149+ let mut m = with_identity();
2150+ m.token = Some("t".into());
2151+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2152+
2153+ let _ = app.update(Event::StartFlush, &mut m);
2154+ let mut cmd = app.update(
2155+ Event::SendResult(Ok(response_with_header(429, "retry-after", "2"))),
2156+ &mut m,
2157+ );
2158+
2159+ assert_eq!(m.outbox.len(), 1, "the message is still queued");
2160+ assert_eq!(m.outbox[0].retries, 0, "and unpunished — it did nothing wrong");
2161+ assert!(!m.flushing, "the guard is released so the retry can run");
2162+ assert_eq!(
2163+ scheduled_delays(&mut cmd),
2164+ vec![2_000],
2165+ "and we wait exactly as long as the relay asked"
2166+ );
2167+ }
2168+
2169+ /// Without the header there is nothing to honour, so fall back to our own
2170+ /// backoff rather than hammering the relay that just asked us to slow down.
2171+ #[test]
2172+ fn a_429_without_retry_after_falls_back_to_the_usual_backoff() {
2173+ let app = Skrepka;
2174+ let mut m = with_identity();
2175+ m.token = Some("t".into());
2176+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2177+
2178+ let _ = app.update(Event::StartFlush, &mut m);
2179+ let mut cmd = app.update(Event::SendResult(Ok(ok_response(429))), &mut m);
2180+
2181+ assert_eq!(m.outbox[0].retries, 0, "still not the message's fault");
2182+ assert_eq!(scheduled_delays(&mut cmd), vec![backoff_ms(m.poll_retries)]);
2183+ }
2184+
2185+ /// `Retry-After` is a number a relay chooses and we obey. Unbounded, one of
2186+ /// them parks the outbox for thirty years — with nothing on screen to say why.
2187+ #[test]
2188+ fn an_absurd_retry_after_is_clamped() {
2189+ let app = Skrepka;
2190+ let mut m = with_identity();
2191+ m.token = Some("t".into());
2192+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2193+
2194+ let _ = app.update(Event::StartFlush, &mut m);
2195+ let mut cmd = app.update(
2196+ Event::SendResult(Ok(response_with_header(429, "retry-after", "999999999"))),
2197+ &mut m,
2198+ );
2199+
2200+ assert_eq!(
2201+ scheduled_delays(&mut cmd),
2202+ vec![MAX_RETRY_AFTER_MS],
2203+ "we wait, but only as long as we are willing to"
2204+ );
2205+ }
2206+
19802207 // -----------------------------------------------------------------------
19812208 // outbox: batching
19822209 // -----------------------------------------------------------------------
@@ -2088,6 +2315,101 @@ mod tests {
20882315 assert!(body <= MAX_SEND_BATCH_BYTES, "the body stays inside the budget");
20892316 }
20902317
2318+ /// `MAX_SEND_BATCH_BYTES` is a *guess* at a cap the protocol never publishes, and
2319+ /// a relay configured below it answers `413`. Read as a transient failure, that
2320+ /// `413` rebuilds the identical batch, is rejected identically, and repeats until
2321+ /// every item has burned its retry budget — the outbox drains without a single
2322+ /// message being delivered. So a `413` measures the cap instead of fighting it.
2323+ #[test]
2324+ fn a_413_halves_the_batch_budget_and_retries_smaller() {
2325+ let app = Skrepka;
2326+ let mut m = with_identity();
2327+ m.token = Some("t".into());
2328+ let peer = peer_hex(9);
2329+ for i in 0..5 {
2330+ m.outbox.push_back(queued(&peer, &format!("m{i}")));
2331+ }
2332+
2333+ // Nothing is known about this relay yet, so all five go out at once.
2334+ let mut cmd = app.update(Event::StartFlush, &mut m);
2335+ let first = sent_batches(&mut cmd);
2336+ assert_eq!(first[0].len(), 5, "the whole run goes out on the first attempt");
2337+ assert!(m.send_batch_budget.is_none(), "and we have assumed nothing");
2338+ let sent_bytes: usize = first[0].iter().map(|e| e.encrypted_blob.len()).sum();
2339+
2340+ // ...and the relay says that was too big.
2341+ let mut cmd = app.update(Event::SendResult(Ok(ok_response(413))), &mut m);
2342+
2343+ assert_eq!(
2344+ m.send_batch_budget,
2345+ Some(sent_bytes / 2),
2346+ "the budget is halved against what actually failed, not against the constant"
2347+ );
2348+ assert_eq!(m.outbox.len(), 5, "a 413 delivered nothing, so nothing is popped");
2349+ assert!(
2350+ m.outbox.iter().all(|i| i.retries == 0),
2351+ "and the items are not charged for the relay being smaller than we guessed"
2352+ );
2353+
2354+ // The retry goes out at once, and it is smaller.
2355+ let second = sent_batches(&mut cmd);
2356+ assert_eq!(second.len(), 1, "retried immediately, not left for the next flush");
2357+ let shrunk = second[0].len();
2358+ assert!(shrunk < 5, "with a smaller batch, got {shrunk} items");
2359+ assert!(shrunk > 0, "but not an empty one");
2360+
2361+ // The relay takes the smaller batch, and exactly it is popped.
2362+ let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
2363+ assert_eq!(m.outbox.len(), 5 - shrunk, "exactly the batch that landed is gone");
2364+ assert_eq!(
2365+ m.send_batch_budget,
2366+ Some(sent_bytes / 2),
2367+ "and what we learned about the relay survives the success — its cap did not grow"
2368+ );
2369+ }
2370+
2371+ /// A batch of one cannot be split, and `flush_next` takes the first item whatever
2372+ /// it weighs — so halving the budget around a lone oversized item would rebuild
2373+ /// the same one-item batch forever, at full speed, with no retry ever charged.
2374+ /// It has to fall through to the retry budget, which is what eventually drops it.
2375+ #[test]
2376+ fn a_413_on_a_single_item_ages_out_instead_of_spinning() {
2377+ let app = Skrepka;
2378+ let mut m = with_identity();
2379+ m.token = Some("t".into());
2380+ m.outbox.push_back(queued(&peer_hex(9), "bigger than this relay allows"));
2381+
2382+ for i in 1..MAX_OUTBOX_RETRIES {
2383+ send_and_answer(&app, &mut m, 413);
2384+ assert_eq!(m.outbox.len(), 1, "still queued after {i} rejection(s)");
2385+ assert_eq!(m.outbox[0].retries, i, "and charged for each of them");
2386+ }
2387+ assert!(
2388+ m.send_batch_budget.is_none(),
2389+ "an unsplittable batch teaches us nothing about the cap"
2390+ );
2391+
2392+ send_and_answer(&app, &mut m, 413);
2393+ assert!(m.outbox.is_empty(), "the item that can never be sent is dropped");
2394+ assert!(
2395+ m.error.is_some_and(|e| e.contains("gave up sending")),
2396+ "and the user is told, rather than the message quietly vanishing"
2397+ );
2398+ }
2399+
2400+ /// What we learned is about *that relay*. A new one is entitled to a bigger cap,
2401+ /// and it will never send the `413` that would correct a budget carried over.
2402+ #[test]
2403+ fn changing_relay_forgets_the_learned_batch_budget() {
2404+ let app = Skrepka;
2405+ let mut m = with_identity();
2406+ m.send_batch_budget = Some(1024);
2407+
2408+ let _ = app.update(Event::SetServerUrl("https://other.example".into()), &mut m);
2409+
2410+ assert!(m.send_batch_budget.is_none());
2411+ }
2412+
20912413 /// An unusable key at the head still has to be discarded, batching or not —
20922414 /// and the discard must not take the rest of the queue with it.
20932415 #[test]
@@ -2378,9 +2700,12 @@ mod tests {
23782700 };
23792701 let page = page_from(9, &m, &profile("Far"), i64::MAX);
23802702 let _ = app.ingest_poll(&mut m, page);
2381− assert!(m.contacts[&peer].last_profile_ts <= now_ms());
2703+ let first_ts = m.contacts[&peer].last_profile_ts;
2704+ assert!(first_ts <= now_ms());
23822705
2383− let page = page_from(9, &m, &profile("Now"), now_ms());
2706+ // A later profile at a strictly greater ts must still apply (the
2707+ // staleness check is strict `>`, not `>=`).
2708+ let page = page_from(9, &m, &profile("Now"), first_ts + 1);
23842709 let _ = app.ingest_poll(&mut m, page);
23852710 assert_eq!(
23862711 m.contacts[&peer].display_name, "Now",
@@ -3209,6 +3534,43 @@ mod tests {
32093534 assert_eq!(m.contacts[&peer].display_name, "Mallory");
32103535 }
32113536
3537+ /// A relay cannot forge mail from us — the sender identity is signed inside the
3538+ /// AEAD — but it can *echo back* a blob we sent through it, and one addressed to
3539+ /// ourselves decrypts perfectly well. Nothing downstream would notice: it would
3540+ /// file our own words as incoming, put our own key in our contact list (which
3541+ /// `AddContact` explicitly refuses), and queue a delivery ack addressed to us —
3542+ /// which flushes, comes back, and is acked again.
3543+ #[test]
3544+ fn a_message_from_ourselves_is_dropped() {
3545+ let app = Skrepka;
3546+ let mut m = with_identity();
3547+ let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
3548+
3549+ let json = protocol::serialize_payload(&text("m1", "echo"), now_ms());
3550+ let blob = crate::crypto::encrypt(
3551+ &mut rand_core::OsRng,
3552+ &me,
3553+ &me.public_key(),
3554+ json.as_bytes(),
3555+ )
3556+ .unwrap();
3557+ let page = PollResp {
3558+ events: vec![PollEvent {
3559+ encrypted_blob: hex::encode(blob),
3560+ }],
3561+ cursor: 1,
3562+ };
3563+
3564+ let _ = app.ingest_poll(&mut m, page);
3565+
3566+ assert!(
3567+ !m.contacts.contains_key(&m.my_pubkey),
3568+ "we are not our own contact"
3569+ );
3570+ assert!(m.messages.is_empty(), "and our own words are not incoming mail");
3571+ assert!(m.outbox.is_empty(), "and we do not ack ourselves");
3572+ }
3573+
32123574 /// `polling` gates the whole long-poll loop and is cleared only by a
32133575 /// `PollResult`. If the shell ever drops the HTTP effect, the client goes
32143576 /// silent forever with no visible error.
core/src/model.rs−1
@@ -251,6 +251,5 @@ pub struct ViewModel {
251251 pub active_peer_ob: String,
252252 pub active_peer_blocked: bool,
253253 pub messages: Vec<MessageVM>,
254− pub compose: String,
255254 pub error: String,
256255}
server.knot+80−8
@@ -93,6 +93,21 @@ maxPollEvents = 50
9393-- Per-peer ceiling on outstanding forward attempts.
9494maxForwardsPerServer : Int
9595maxForwardsPerServer = 2000
96+-- Global ceiling on the `*forwards` outbox. `maxForwardsPerServer` bounds any one
97+-- peer, but a recipient gossiped as present on many servers fans one blob out to
98+-- all of them, so the total is bounded only by (peers x per-peer cap). This caps
99+-- the whole outbox.
100+--
101+-- Evicts the *highest*-retry entries first: those are the ones closest to
102+-- `maxForwardRetries` (about to be abandoned anyway), so pressure is shed onto
103+-- failing peers rather than healthy ones. Freshly-staged rows carry `retries: 0`
104+-- and so always survive their own capping.
105+--
106+-- Dropping a forward is not message loss: the `*messages` row it points at stays
107+-- put, so the recipient still gets the blob by polling here, and a later presence
108+-- announce re-stages the forward via `forwardQueuedToServer`.
109+maxForwardsTotal : Int
110+maxForwardsTotal = 50000
96111-- Max accepted length of a server hostname.
97112maxServerNameLen : Int
98113maxServerNameLen = 128
@@ -138,6 +153,33 @@ maxPresence = 100000
138153-- each pass `isBadServerName`.
139154maxPresencePerServer : Int
140155maxPresencePerServer = 5000
156+-- Per-recipient ceiling on stored mailbox rows. Without a cap a single
157+-- authenticated sender can queue unbounded data for one recipient
158+-- (`maxBatchSize` 100 messages/request x `sendRateLimit` 60 requests/min =
159+-- 6000/min, each blob up to 20 MiB) and only the 30-day `messageMaxAge` TTL
160+-- ever reclaims it. Past the cap `appendMessage` drops the oldest rows (lowest
161+-- `receivedAt`) — the same rows the TTL would eventually drop, just sooner.
162+--
163+-- The recipient's client bounds what it *processes* per poll (MAX_POLL_TOTAL_BYTES
164+-- in core/src/app.rs), but nothing bounded what the server *stores* before this.
165+--
166+-- Dropping a row orphans any `*forwards` entry for it; `pruneExpired` collects
167+-- those (it drops forwards whose message no longer exists). Its `deliveredTo`
168+-- set is lost with it, which is harmless — the blob is gone, so there is
169+-- nothing left to re-forward.
170+--
171+-- LIMITATION — evicting on overflow means a flood can now *destroy* mail, not
172+-- just pile it up: an attacker who fills a recipient's mailbox pushes that
173+-- recipient's genuine undelivered messages out of the window. Pubkeys are free
174+-- to mint, so "authenticated sender" is no barrier. This is a trade, not a fix
175+-- — the alternative (reject once full) hands the same attacker a permanent
176+-- block on *all* new mail to that recipient, which is strictly worse: here at
177+-- least the newest messages always land. It only bites a recipient who is
178+-- offline long enough to accumulate `maxMessagesPerRecipient`, since polling
179+-- drains the mailbox (`ackPolledMessages`). A real fix prices sending — see
180+-- the same argument for sessions on `maxSessions`, and PROTOCOL.md §10.
181+maxMessagesPerRecipient : Int
182+maxMessagesPerRecipient = 10000
141183-- Validity window for an auth challenge before it must be re-issued (1 min).
142184challengeExpiry : Int<Ms>
143185challengeExpiry = 60000
@@ -470,6 +512,15 @@ capPerKey = \items pubkey maxN -> union (filter (\x -> not (x.pubkey == pubkey))
470512capPerServer : [{server: Text, expiresAt: Timestamp | r}] -> Text -> Int -> [{server: Text, expiresAt: Timestamp | r}]
471513capPerServer = \items server maxN -> union (filter (\x -> not (x.server == server)) items) (capList (filter (\x -> x.server == server) items) maxN)
472514
515+-- Cap the whole forwards outbox to `maxForwardsTotal`, evicting the highest-retry
516+-- entries first — see `maxForwardsTotal` for why those and not the oldest.
517+-- Deliberately not `capList`: that keys on `expiresAt`, and `ForwardEntry` carries
518+-- no timestamp (the same reason `retryPendingForwards` has to sort on the blob).
519+-- Applied by both writers that grow `*forwards` (`forwardQueuedToServer` and
520+-- `handleSendMessages`); the retry sweep only ever shrinks it.
521+capForwards : [ForwardEntry] -> [ForwardEntry]
522+capForwards = \fwds -> take maxForwardsTotal (sortBy (\f -> f.retries) fwds)
523+
473524-- Random hex token: one random hex char per recursive call. `n` is the
474525-- desired length in characters (so n=48 yields 24 random bytes of entropy).
475526-- Session bearers, /auth challenges, and federation auth nonces all use n=48
@@ -872,7 +923,18 @@ appendMessage = \toKey encryptedBlob t -> do
872923 seqRows <- *seqCounter
873924 let seqTs = max t (currentSeq seqRows + 1)
874925 *seqCounter = [{value: seqTs}]
875− *messages = union msgs [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}])
926+ -- Enforce `maxMessagesPerRecipient` before the append: evict the oldest rows
927+ -- for this recipient so the post-append count lands exactly on the ceiling.
928+ -- `- 1` reserves the slot the new row is about to take.
929+ --
930+ -- Eviction keys on `encryptedBlob` rather than the row itself because
931+ -- (toKey, encryptedBlob) is unique by this function's own dedup invariant
932+ -- (the `when` guard above), so a blob identifies at most one row per key.
933+ let forRecipient = filter (\m -> m.toKey == toKey) msgs
934+ let excess = max 0 (count forRecipient - (maxMessagesPerRecipient - 1))
935+ let evicted = map (\m -> m.encryptedBlob) (take excess (sortBy (\m -> m.receivedAt) forRecipient))
936+ let kept = filter (\m -> not (m.toKey == toKey && elem m.encryptedBlob evicted)) msgs
937+ *messages = union kept [{toKey, encryptedBlob, receivedAt: seqTs, deliveredTo: []}])
876938
877939-- Record that `server` has accepted a federated forward of this message, so a
878940-- later presence re-announce of `toKey` doesn't re-forward the same blob to a peer
@@ -1084,7 +1146,7 @@ forwardQueuedToServer = \pubkey serverName -> atomic (do
10841146 let notYetDelivered = \m -> not any (\d -> d.server == server) m.deliveredTo
10851147 let candidates = filter (\m -> m.toKey == pubkey && notYetForwarded m && notYetDelivered m) msgs
10861148 let newFwds = map (\m -> {toServer: server, m.toKey, m.encryptedBlob, retries: 0}) (take budget (sortBy (\m -> m.receivedAt) candidates))
1087− *forwards = union fwds newFwds)
1149+ *forwards = capForwards (union fwds newFwds))
10881150
10891151-- ============================================
10901152-- Handler: challenge / verify
@@ -1127,7 +1189,7 @@ handleChallenge = \pubkey clientIp -> do
11271189-- On success, mints a session token, records local presence (`pubkey` is now
11281190-- live on this server), and gossips an `online` event to known peers. If
11291191-- `revokeOthers` is set, all other sessions for the same pubkey are dropped.
1130−handleVerifyAuth : PubkeyHex -> Text -> Text -> Bool -> Text -> IO {rw *challenges, rw *forwardFailures, rw *presence, rw *sessions, network, clock, random} (Result HttpError {expiresAt: Timestamp, token: Text})
1192+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})
11311193handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
11321194 t <- now
11331195 let expiry = t + sessionExpiry
@@ -1158,13 +1220,23 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
11581220 let newSession = {token, pubkey, expiresAt: expiry, ip: clientIp}
11591221 -- LRU-evicts the soonest-to-expire session when the pool is full; this global
11601222 -- eviction is Sybil-floodable, see the LIMITATION note on `maxSessions`.
1161− *sessions = capList (union pruned [newSession]) maxSessions
1223+ let pooled = union pruned [newSession]
1224+ -- Count what `capList` is about to shed, so the handler can warn about it
1225+ -- below. That flood has no other observable signature: evicted users just
1226+ -- start seeing 401s and silently re-authenticate, which looks like normal
1227+ -- traffic in the access log. A sustained nonzero rate here is the alert.
1228+ let evicted = max 0 (count pooled - maxSessions)
1229+ *sessions = capList pooled maxSessions
11621230 peers <- refreshLocalPresence pubkey expiry
1163− yield (Just {value: peers}))
1231+ yield (Just {value: {peers, evicted}}))
11641232 case result of
11651233 Nothing {} -> yield (Err {error: {status: 401, message: "invalid"}})
1166− Just {value: peers} -> do
1167− when federationEnabled (fork (sendBatchedGossip peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
1234+ Just {value: r} -> do
1235+ -- Warn outside the `atomic` block, never inside: an STM transaction can
1236+ -- retry, and a console write is not rolled back with it — logging inside
1237+ -- would emit one line per retry attempt rather than one per eviction.
1238+ when (r.evicted > 0) (logWarn ("session pool full: evicted " ++ show r.evicted ++ " session(s) to seat a new login (maxSessions=" ++ show maxSessions ++ ")"))
1239+ when federationEnabled (fork (sendBatchedGossip r.peers [{eventType: "online", pubkey: Just {value: pubkey}}]))
11681240 yield (Ok {value: {token, expiresAt: expiry}})
11691241
11701242-- ============================================
@@ -1346,7 +1418,7 @@ handleSendMessages = \messages authorization clientIp -> do
13461418 when federationEnabled (do
13471419 fwds <- *forwards
13481420 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)
1349− *forwards = union fwds newFwds))
1421+ *forwards = capForwards (union fwds newFwds)))
13501422 yield staged)
13511423 when federationEnabled (forEach committed (\c -> fork (forkForwardsTo c.to c.encryptedBlob c.remoteServers)))
13521424 yield (Ok {value: {}})