on master help sign in
Fifth-pass audit: fix send_text contact-cap ordering, spec version-byte omissions, zstd level, DeleteContact UI, comment mismatch

B1: send_text checked MAX_CONTACTS after clearing compose and inserting the message — a rejection at the cap lost the user's text and left an invisible orphan conversation that was never persisted. Move the check before any mutation. S1: PROTOCOL.md §3 encryption flow step 8 omitted the version byte from the blob layout. The implementation (and the Encrypted Blob Format section below it) correctly prepends version_byte. S2: PROTOCOL.md §3 decryption flow step 1 omitted reading/checking the version byte. The implementation reads and checks it first before splitting. G2: zstd compression level 0 is effectively no compression. Level 3 adds negligible CPU cost and may help messages fit into smaller padding buckets. G1: DeleteContact event existed in the core but had no UI. Add DeleteContactButton with a confirmation dialog to both the chat header menu and the conversations list context menu. G3: Correct the misleading comment in view() that claimed ComposeChanged fires on every keystroke — the Swift shell only sends it before SendText.

commitf34323c9a4802c5e61c1ea998eadd70d88721d6a authoriko <mail@iko.soy> dateMon 13 Jul 2026, 04:33 (3 months ago) parent92e93f0
4 files changed+90−20
PROTOCOL.md+3−2 apple/SkrepkaApp/Views.swift+30 core/src/app.rs+56−17 core/src/crypto.rs+1−1
PROTOCOL.md+3−2
@@ -136,12 +136,13 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
136136 ```
1371378. Emit the on-wire blob:
138138 ```
139− blob = ephemeral_public || nonce || ciphertext
139+ blob = version_byte || ephemeral_public || nonce || ciphertext
140140 ```
141+ where `version_byte` is `0x01` (see *Cryptographic Versioning* below).
141142
142143**Decryption flow:**
143144
144−1. Split the blob: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
145+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`.
1451462. 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`.
1461473. AEAD-decrypt the ciphertext to recover the inner buffer.
1471484. 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).
apple/SkrepkaApp/Views.swift+30
@@ -161,6 +161,34 @@ struct BlockToggle: View {
161161 }
162162}
163163
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+
164192struct ConversationsView: View {
165193 @ObservedObject var core: Core
166194
@@ -218,6 +246,7 @@ struct ConversationsView: View {
218246 }
219247 .contextMenu {
220248 BlockToggle(core: core, peer: contact.pubkey, blocked: contact.blocked)
249+ DeleteContactButton(core: core, peer: contact.pubkey)
221250 }
222251 }
223252}
@@ -259,6 +288,7 @@ struct ChatView: View {
259288 peer: core.view.activePeer,
260289 blocked: core.view.activePeerBlocked
261290 )
291+ DeleteContactButton(core: core, peer: core.view.activePeer)
262292 } label: {
263293 Image(systemName: "ellipsis.circle")
264294 }
core/src/app.rs+56−17
@@ -1337,18 +1337,13 @@ impl App for Skrepka {
13371337
13381338 let active_peer = model.active_peer.clone().unwrap_or_default();
13391339
1340− // `view()` runs on every render, and `ComposeChanged` renders on every
1341− // keystroke. A contact's `photo` is a base64 avatar — tens of KiB each —
1342− // so cloning all of them per keypress meant megabytes of allocation and
1343− // bincode-encoding to type one message.
1344− //
1345− // Only the chat page has a composer (`ComposeChanged` is sent from
1346− // `ChatView` and nowhere else), so only the chat page is on that hot path
1347− // — and while it is up, the only avatar on screen is the peer we are
1348− // talking to. Every *other* page renders from a cold event, and the
1349− // conversations list draws an avatar per row, so it gets the real photos.
1350− // Withholding them there would not save a single keystroke's work; it
1351− // would just replace every contact's picture with their initials.
1340+ // `view()` is called after every event the core processes. The
1341+ // per-keystroke optimization below assumes `ComposeChanged` fires on
1342+ // every keystroke, but the Swift shell only sends it before `SendText`
1343+ // — so in practice this path runs once per message, not per keypress.
1344+ // The optimization is still correct (it withholds avatars on the chat
1345+ // page regardless of how often `view` runs), just the original comment
1346+ // overstated the frequency.
13521347 let chat_open = model.page == Page::Chat;
13531348
13541349 let mut contacts: Vec<ContactVM> = model
@@ -1535,6 +1530,20 @@ impl Skrepka {
15351530 if body.is_empty() {
15361531 return render();
15371532 }
1533+
1534+ // Check the contact cap *before* mutating anything. The old order
1535+ // cleared `compose` and inserted the message into `model.messages`
1536+ // first, then hit the cap and returned early — so the user's text was
1537+ // gone (compose cleared), the message sat in an invisible orphan
1538+ // conversation (no contact entry to click on), and nothing was
1539+ // persisted (no `persist_messages` before the early return). On next
1540+ // launch the message was silently lost.
1541+ let was_new = !model.contacts.contains_key(&peer);
1542+ if was_new && model.contacts.len() >= MAX_CONTACTS {
1543+ model.error = Some("contact list is full".into());
1544+ return render();
1545+ }
1546+
15381547 model.compose.clear();
15391548 let ts = now_ms();
15401549 let id = uuid::Uuid::new_v4().to_string();
@@ -1559,11 +1568,6 @@ impl Skrepka {
15591568 // the same `MAX_CONTACTS` cap `ingest_poll` enforces. This is
15601569 // user-driven (not an attack vector), but the cap exists to bound the
15611570 // `contacts` kv blob, which is rewritten in full on every change.
1562− let was_new = !model.contacts.contains_key(&peer);
1563− if was_new && model.contacts.len() >= MAX_CONTACTS {
1564− model.error = Some("contact list is full".into());
1565− return render();
1566− }
15671571 if was_new {
15681572 model
15691573 .contacts
@@ -3309,6 +3313,41 @@ mod tests {
33093313 assert!(m.outbox.is_empty());
33103314 }
33113315
3316+ /// The contact cap must be checked *before* mutating anything. The old
3317+ /// order cleared `compose` and inserted the message into `model.messages`
3318+ /// first, then hit the cap and returned early — losing the user's text and
3319+ /// leaving an invisible orphan conversation that was never persisted.
3320+ #[test]
3321+ fn send_text_to_a_new_peer_at_the_contact_cap_preserves_compose() {
3322+ let app = Skrepka;
3323+ let mut m = with_identity();
3324+ // Fill the contact list to the cap.
3325+ for i in 0..MAX_CONTACTS {
3326+ let k = format!("{i:064x}");
3327+ m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
3328+ }
3329+ let new_peer = peer_hex(9);
3330+ m.active_peer = Some(new_peer.clone());
3331+ m.compose = "hello new friend".into();
3332+
3333+ let _ = app.update(Event::SendText, &mut m);
3334+
3335+ assert_eq!(
3336+ m.compose, "hello new friend",
3337+ "compose must not be cleared on rejection"
3338+ );
3339+ assert!(
3340+ m.messages.get(&new_peer).is_none_or(Vec::is_empty),
3341+ "no orphan conversation must be created"
3342+ );
3343+ assert!(m.outbox.is_empty(), "nothing must be queued");
3344+ assert!(!m.contacts.contains_key(&new_peer), "no contact created");
3345+ assert!(
3346+ m.error.as_ref().is_some_and(|e| e.contains("full")),
3347+ "the user is told why"
3348+ );
3349+ }
3350+
33123351 #[test]
33133352 fn set_blocked_toggles_and_view_reflects_it() {
33143353 let app = Skrepka;
core/src/crypto.rs+1−1
@@ -260,7 +260,7 @@ fn derive_key(
260260
261261fn compress(plaintext: &[u8]) -> Vec<u8> {
262262 // zstd frame with embedded content size; decodable without a capacity hint.
263− zstd::encode_all(plaintext, 0).expect("zstd compression is infallible for in-memory input")
263+ zstd::encode_all(plaintext, 3).expect("zstd compression is infallible for in-memory input")
264264}
265265
266266/// Decompress a zstd frame, refusing to produce more than `MAX_PLAINTEXT_LEN`.