on master help sign in
Third-pass audit: fix cursor-loss, block-check, zeroization, spec drift

F (high): ingest_poll advanced cursor before the budget check — if the page budget was exhausted mid-page, the cursor stayed at page.cursor and the next poll acked the whole page to the relay, deleting every unprocessed event. Save and restore the pre-page cursor on budget exhaustion. G (medium): send_text had no block check — a user could keep sending to a blocked contact. Blocking now cuts both directions: send_text refuses with an error, and the Swift ChatView composer disables both the text field and send button for blocked peers. H (low): crypto::encrypt zeroized the inner buffer but not the compressed and signed intermediates, leaving compressed plaintext in freed memory. Both are now Zeroizing<Vec<u8>>. I (spec): PROTOCOL.md §10 said 'no version field' but §3 already has one. Updated the risk table to match. J (spec): PROTOCOL.md §4 said blocked messages 'are still received and decrypted' but the implementation drops them before decryption. Updated to match. Also documented the bare-413 operator requirement in the §6 error table and added two new tests (111 total, clippy clean).

commitb8d30b6af14e17c6d64bb6eaa2ee47c0897d533f authoriko <mail@iko.soy> dateMon 13 Jul 2026, 04:11 (3 months ago) parent3154319
5 files changed+163−6
PROTOCOL-REVIEW.md+57 PROTOCOL.md+3−3 apple/SkrepkaApp/Views.swift+3−1 core/src/app.rs+98 core/src/crypto.rs+2−2
PROTOCOL-REVIEW.md+57
@@ -280,3 +280,60 @@ blocks until every `fetch` resolves. The prune sweep's writes conflict with the
280280retry loop's atomic blocks. A fix needs either a `fetch` deadline or a
281281transaction that yields between per-peer batches, neither of which the current
282282`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 before decryption. The spec
338+now accurately describes the implementation: messages are dropped without
339+decryption or storage, and blocking is bidirectional.
PROTOCOL.md+3−3
@@ -292,7 +292,7 @@ This is the only delivery-confirmation channel in the protocol: the server itsel
292292
293293### Blocking
294294
295−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.
295+Blocking is client-side only. A blocked sender's messages are dropped without decryption or storage — the reference client does not decrypt, store, display, or ack them. 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.
296296
297297---
298298
@@ -411,7 +411,7 @@ Errors return a non-2xx HTTP status with a body of:
411411| 401 | `unauthorized` | `/poll` or `/messages` with a missing, invalid, or expired token, or a request from a different IP |
412412| 403 | `federation_disabled` | A `/federation/*` endpoint was called while federation is disabled |
413413| 404 | `no_presence` | Federated forward arrived for a recipient with no live local session |
414−| 413 | `batch_too_large` | A `/messages` batch exceeds the 100-message cap |
414+| 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. |
415415| 429 | (HTTP only) | Per-route rate limit exceeded |
416416| 503 | `capacity` | Server-side resource cap reached |
417417
@@ -670,7 +670,7 @@ Skrepka protects message **content and sender identity** from servers and networ
670670| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. The damage is bounded per payload type: `text` is deduplicated by `id`, `delivery.ack` is idempotent, and `profile` — which carries no `id` — is guarded by the per-contact `ts` staleness check of §4, which the reference client enforces, so a replayed stale `profile` is dropped rather than rolling a contact's cached profile back. A client that omits that check is open to profile rollback. |
671671| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated, and the harm is *active*, not merely passive observation: an attacker announces the victim online at a host it controls and origin relays forward the victim's queued ciphertext there. See *Open federation* below. |
672672| **Open federation — forward injection** | `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into a currently-online recipient's mailbox. See *Open federation* below. |
673−| **No cryptographic agility** | The wire format has no version field and a fixed HKDF `info` (§3); the AEAD/KDF/curve cannot be migrated in-band. |
673+| **No crypto rotation performed** | The wire format carries a version byte (§3, *Cryptographic Versioning*) and a fixed HKDF `info` (`"skrepka-v1"`), so a future revision *can* introduce new AEAD/KDF/curve choices by bumping both. No such rotation has been performed, and a client that does not recognize the version byte rejects the blob outright — but there is no negotiation, so an old client cannot discover that a new one exists. |
674674| **TOFU only** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band to detect MITM at first contact. |
675675
676676### Open Federation (accepted limitation of v0.1)
apple/SkrepkaApp/Views.swift+3−1
@@ -293,6 +293,7 @@ struct ChatView: View {
293293 TextField("Message", text: $draft, axis: .vertical)
294294 .textFieldStyle(.roundedBorder)
295295 .lineLimit(1...4)
296+ .disabled(core.view.activePeerBlocked)
296297 Button {
297298 let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
298299 guard !text.isEmpty else { return }
@@ -302,7 +303,8 @@ struct ChatView: View {
302303 } label: {
303304 Image(systemName: "arrow.up.circle.fill").font(.title)
304305 }
305− .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
306+ .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
307+ || core.view.activePeerBlocked)
306308 }
307309 .padding(.horizontal).padding(.vertical, 8)
308310 .background(.bar)
core/src/app.rs+98
@@ -1511,6 +1511,14 @@ impl Skrepka {
15111511 let Some(peer) = model.active_peer.clone() else {
15121512 return render();
15131513 };
1514+ // Blocking cuts the peer off in both directions (PROTOCOL.md §4). The
1515+ // ingest side drops their messages; the send side must refuse too, or
1516+ // a blocked peer still receives our texts, acks, and the liveness
1517+ // signal that blocking is supposed to cut off.
1518+ if model.contacts.get(&peer).is_some_and(|c| c.blocked) {
1519+ model.error = Some("cannot send to a blocked contact".into());
1520+ return render();
1521+ }
15141522 let body = model.compose.trim().to_string();
15151523 if body.is_empty() {
15161524 return render();
@@ -1768,6 +1776,7 @@ impl Skrepka {
17681776 if model.kv_load_failed {
17691777 return Command::done();
17701778 }
1779+ let prev_cursor = model.cursor;
17711780 if page.cursor > model.cursor {
17721781 model.cursor = page.cursor;
17731782 }
@@ -1797,6 +1806,13 @@ impl Skrepka {
17971806 // A page this large is a misbehaving relay, not real mail.
17981807 // Stop here; the rest of the page is discarded exactly as an
17991808 // undecryptable blob would be.
1809+ //
1810+ // Restore the cursor: it was advanced to `page.cursor` above,
1811+ // but we have not processed the remaining events. If we keep
1812+ // the advanced cursor, the next poll acks the whole page to
1813+ // the relay — including every event we skipped — and the
1814+ // relay deletes them. Those messages are permanently lost.
1815+ model.cursor = prev_cursor;
18001816 model.error = Some("relay sent an oversized poll page".into());
18011817 break;
18021818 }
@@ -4176,4 +4192,86 @@ mod tests {
41764192 "last_ack_ts advances to the fresh ack's ts"
41774193 );
41784194 }
4195+
4196+ // -----------------------------------------------------------------------
4197+ // cursor restoration on budget exhaustion
4198+ // -----------------------------------------------------------------------
4199+
4200+ /// The cursor is advanced to `page.cursor` before the event loop runs. If the
4201+ /// poll-byte budget is exhausted mid-page, the loop breaks — but the cursor
4202+ /// must be restored to its pre-page value. Otherwise the next poll acks the
4203+ /// whole page to the relay, which deletes every event we skipped: permanent
4204+ /// message loss.
4205+ #[test]
4206+ fn a_budget_exhausted_mid_page_restores_the_cursor() {
4207+ let app = Skrepka;
4208+ let mut m = with_identity();
4209+ let peer = peer_hex(9);
4210+ m.contacts
4211+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4212+ m.cursor = 100;
4213+
4214+ // Two blobs, each at the per-blob ceiling (MAX_BLOB_LEN hex chars =
4215+ // MAX_BLOB_LEN/2 decoded bytes = 21 MiB). Individually they pass the
4216+ // per-blob size gate; together they exceed the 64 MiB page budget.
4217+ // The first passes the budget check (21 < 64) and fails decrypt (it's
4218+ // not a real blob); the second fails the budget check (21 + 21 + 21 > 64).
4219+ // Three blobs to be sure: 3 × 21 = 63 MiB (just under 64), so use four.
4220+ let big_hex = "ab".repeat(MAX_BLOB_LEN); // MAX_BLOB_LEN hex chars = MAX_BLOB_LEN/2 bytes
4221+ let page = PollResp {
4222+ events: (0..4)
4223+ .map(|_| PollEvent {
4224+ encrypted_blob: big_hex.clone(),
4225+ })
4226+ .collect(),
4227+ cursor: 200,
4228+ };
4229+ let _ = app.ingest_poll(&mut m, page);
4230+
4231+ assert_eq!(
4232+ m.cursor, 100,
4233+ "the cursor must be restored — the page was not fully processed"
4234+ );
4235+ assert!(
4236+ m.error.as_ref().is_some_and(|e| e.contains("oversized")),
4237+ "the user is told"
4238+ );
4239+ assert!(
4240+ m.messages.get(&peer).is_none_or(Vec::is_empty),
4241+ "nothing was stored from the oversized page"
4242+ );
4243+ }
4244+
4245+ // -----------------------------------------------------------------------
4246+ // send_text block check
4247+ // -----------------------------------------------------------------------
4248+
4249+ /// Blocking cuts a peer off in both directions. `send_text` must refuse to
4250+ /// enqueue a message to a blocked contact — otherwise the block only stops
4251+ /// their incoming mail while we keep sending them ours.
4252+ #[test]
4253+ fn send_text_to_a_blocked_peer_is_refused() {
4254+ let app = Skrepka;
4255+ let mut m = with_identity();
4256+ let peer = peer_hex(9);
4257+ let mut contact = Contact::new(peer.clone(), "Enemy".into(), 0);
4258+ contact.blocked = true;
4259+ m.contacts.insert(peer.clone(), contact);
4260+ m.active_peer = Some(peer.clone());
4261+ m.compose = "hello?".into();
4262+
4263+ let _ = app.update(Event::SendText, &mut m);
4264+
4265+ assert!(
4266+ m.outbox.is_empty(),
4267+ "no message is queued for a blocked peer"
4268+ );
4269+ assert!(
4270+ m.error.as_ref().is_some_and(|e| e.contains("blocked")),
4271+ "the user is told why"
4272+ );
4273+ // The compose buffer is not cleared — the user might want to unblock
4274+ // and resend, and losing their draft would be surprising.
4275+ assert_eq!(m.compose, "hello?");
4276+ }
41794277}
core/src/crypto.rs+2−2
@@ -301,7 +301,7 @@ pub fn encrypt(
301301 let raw_secret = x25519(&eph_priv, &recip_x)?;
302302 let key = derive_key(&raw_secret, &eph_pub, &recip_x);
303303
304− let compressed = compress(plaintext);
304+ let compressed = Zeroizing::new(compress(plaintext));
305305
306306 // Compute padding: round the full on-wire blob size up to a bucket boundary
307307 // so relays and network observers cannot tell the exact (compressed) length.
@@ -319,7 +319,7 @@ pub fn encrypt(
319319
320320 // Sign recipient_pub || compressed_len_bytes || compressed || padding
321321 // (i.e. recipient_pub || everything after the 96-byte header in inner).
322− let mut signed = Vec::with_capacity(32 + 4 + compressed.len() + padding.len());
322+ let mut signed = Zeroizing::new(Vec::with_capacity(32 + 4 + compressed.len() + padding.len()));
323323 signed.extend_from_slice(recipient_ed_pub);
324324 signed.extend_from_slice(&compressed_len_bytes);
325325 signed.extend_from_slice(&compressed);