on master help sign in
Sixth-pass audit: send-side body/profile/nickname length checks, zeroize Decrypted plaintext, send watchdog

K. send_text did not check body.len() against MAX_BODY_LEN — the recipient's parse_payload silently drops oversized text, so the message would be stored locally, sent to the relay, and marked 'sent' but never arrive. Now refused with 'message too long' before queueing. L. SaveProfile did not check display_name/bio/photo against their caps — every recipient would silently drop the oversized profile, creating a silent divergence between sender and contacts. All three fields now validated before save/broadcast. M. Decrypted.plaintext was a plain Vec<u8> — the decrypted message content sat in freed memory. Now Zeroizing<Vec<u8>> (Deref makes &dec.plaintext work as &[u8] via auto-deref). N. No send watchdog — if SendResult never came back (dropped HTTP effect), flushing stayed true forever and the outbox was permanently stuck. Added FlushWatchdog event (90s, same pattern as the poll watchdog) that releases the guard and retries the flush. O. AddContact did not bound nickname length — a megabyte nickname would bloat the contacts kv blob. Now checked against MAX_DISPLAY_NAME_LEN. 120 tests pass (8 new), clippy clean.

commit4e0372227a63d8ec68770645db821fafbb81641b authoriko <mail@iko.soy> dateMon 13 Jul 2026, 05:00 (3 months ago) parentf34323c
3 files changed+307−9
PROTOCOL-REVIEW.md+56 core/src/app.rs+238−1 core/src/crypto.rs+13−8
PROTOCOL-REVIEW.md+56
@@ -339,3 +339,59 @@ identity is inside the AEAD ciphertext, so decryption is necessary to learn who
339339the message is from). The spec now accurately describes the implementation:
340340messages are decrypted to recover the sender identity, then dropped without
341341storage, 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`.
core/src/app.rs+238−1
@@ -73,6 +73,20 @@ const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
7373/// relay, so leave the margin.
7474const POLL_WATCHDOG_MS: u64 = 90_000;
7575
76+/// How long a send may stay in flight before we assume the shell lost it.
77+///
78+/// `flushing` gates the whole send loop, and it is only cleared by a
79+/// `SendResult`. If the shell ever drops the HTTP effect (a cancelled task, a
80+/// backgrounded app, a bug), `flushing` stays `true` and the outbox is
81+/// permanently stuck — new messages queue up but never send. The watchdog
82+/// releases the guard so the next `StartFlush` can retry.
83+///
84+/// Kept above the shell's 70 s HTTP timeout for the same reason as
85+/// `POLL_WATCHDOG_MS`: a misfire on a merely slow send is survivable (the late
86+/// `SendResult` finds `in_flight` already cleared and pops nothing), but each
87+/// one costs a redundant request.
88+const FLUSH_WATCHDOG_MS: u64 = 90_000;
89+
7690/// Messages per `/messages` request.
7791///
7892/// The relay rejects a longer batch outright (`413 batch_too_large`,
@@ -210,6 +224,11 @@ pub enum Event {
210224 #[serde(skip)]
211225 #[facet(skip)]
212226 StartFlush,
227+ /// Fires `FLUSH_WATCHDOG_MS` after a flush is issued; a no-op unless the
228+ /// send is somehow still in flight — same pattern as `PollWatchdog`.
229+ #[serde(skip)]
230+ #[facet(skip)]
231+ FlushWatchdog,
213232 #[serde(skip)]
214233 #[facet(skip)]
215234 ChallengeResult(u64, #[facet(opaque)] HttpResult),
@@ -862,6 +881,13 @@ impl App for Skrepka {
862881 }
863882 }
864883 Event::AddContact { input, nickname } => {
884+ // A nickname is stored in the contacts kv blob, which is
885+ // rewritten in full on every change. An unbounded nickname
886+ // bloats the blob and every subsequent write.
887+ if nickname.len() > protocol::MAX_DISPLAY_NAME_LEN {
888+ model.error = Some("nickname too long".into());
889+ return render();
890+ }
865891 match phonemic::try_parse_pubkey(&input) {
866892 Some(hex) if hex != model.my_pubkey => {
867893 model
@@ -933,6 +959,23 @@ impl App for Skrepka {
933959 bio,
934960 photo,
935961 } => {
962+ // The recipient's `parse_payload` enforces these caps and
963+ // silently drops oversized profiles. Without these checks the
964+ // sender's local profile would be saved, broadcast to every
965+ // contact, and silently dropped by every recipient — a silent
966+ // divergence between what the sender sees and what contacts see.
967+ if display_name.chars().count() > protocol::MAX_DISPLAY_NAME_LEN {
968+ model.error = Some("display name too long".into());
969+ return render();
970+ }
971+ if bio.chars().count() > protocol::MAX_BIO_LEN {
972+ model.error = Some("bio too long".into());
973+ return render();
974+ }
975+ if photo.as_ref().is_some_and(|p| p.len() > protocol::MAX_PHOTO_LEN) {
976+ model.error = Some("photo too large".into());
977+ return render();
978+ }
936979 model.profile = OwnProfile {
937980 display_name,
938981 bio,
@@ -1214,6 +1257,19 @@ impl App for Skrepka {
12141257
12151258 // ---------------- outbox ----------------
12161259 Event::StartFlush => self.flush_next(model),
1260+ Event::FlushWatchdog => {
1261+ // The send request never came back — the shell dropped the HTTP
1262+ // effect (backgrounded app, bug), and `flushing` has been stuck
1263+ // ever since. Release the guard and retry the flush. The late
1264+ // `SendResult`, if it ever arrives, finds `in_flight` already
1265+ // cleared and pops nothing.
1266+ if !model.flushing {
1267+ return Command::done();
1268+ }
1269+ model.flushing = false;
1270+ model.in_flight = None;
1271+ self.flush_next(model)
1272+ }
12171273 Event::SendResult(Ok(resp)) => {
12181274 // The in-flight send is over; `flush_next` refuses to run while
12191275 // this is set, so it must be cleared before *any* branch below.
@@ -1530,6 +1586,15 @@ impl Skrepka {
15301586 if body.is_empty() {
15311587 return render();
15321588 }
1589+ // The recipient's `parse_payload` enforces `MAX_BODY_LEN` and silently
1590+ // drops oversized text. Without this check the message would be stored
1591+ // locally, sent to the relay, and marked "sent" — but never arrive, never
1592+ // get acked, and the sender would see it stuck as "sent" forever with no
1593+ // error.
1594+ if body.len() > protocol::MAX_BODY_LEN {
1595+ model.error = Some("message too long".into());
1596+ return render();
1597+ }
15331598
15341599 // Check the contact cap *before* mutating anything. The old order
15351600 // cleared `compose` and inserted the message into `model.messages`
@@ -1733,7 +1798,12 @@ impl Skrepka {
17331798 .header("authorization", format!("Bearer {token}"))
17341799 .body_json(&SendBatch { messages })
17351800 {
1736− Ok(req) => persist.and(req.build().then_send(Event::SendResult)),
1801+ Ok(req) => persist.and(req.build().then_send(Event::SendResult))
1802+ .and(
1803+ Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
1804+ .0
1805+ .then_send(|_| Event::FlushWatchdog),
1806+ ),
17371807 Err(_) => {
17381808 // Not a dead loop: the next StartFlush (from a poll page or a
17391809 // fresh send) retries the head of the outbox.
@@ -4313,6 +4383,10 @@ mod tests {
43134383 // send_text block check
43144384 // -----------------------------------------------------------------------
43154385
4386+ // -----------------------------------------------------------------------
4387+ // send_text body length check
4388+ // -----------------------------------------------------------------------
4389+
43164390 /// Blocking cuts a peer off in both directions. `send_text` must refuse to
43174391 /// enqueue a message to a blocked contact — otherwise the block only stops
43184392 /// their incoming mail while we keep sending them ours.
@@ -4341,4 +4415,167 @@ mod tests {
43414415 // and resend, and losing their draft would be surprising.
43424416 assert_eq!(m.compose, "hello?");
43434417 }
4418+
4419+ /// A text body over `MAX_BODY_LEN` would be stored locally and sent to the
4420+ /// relay, but the recipient's `parse_payload` silently drops oversized text.
4421+ /// The sender would see it stuck as "sent" forever with no error.
4422+ #[test]
4423+ fn send_text_refuses_an_oversized_body() {
4424+ let app = Skrepka;
4425+ let mut m = with_identity();
4426+ let peer = peer_hex(9);
4427+ m.active_peer = Some(peer.clone());
4428+ m.compose = "x".repeat(protocol::MAX_BODY_LEN + 1);
4429+
4430+ let _ = app.update(Event::SendText, &mut m);
4431+
4432+ assert!(m.outbox.is_empty(), "nothing is queued");
4433+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty), "nothing stored");
4434+ assert!(!m.compose.is_empty(), "compose is preserved");
4435+ assert!(m.error.as_ref().is_some_and(|e| e.contains("too long")));
4436+ }
4437+
4438+ // -----------------------------------------------------------------------
4439+ // SaveProfile field length checks
4440+ // -----------------------------------------------------------------------
4441+
4442+ #[test]
4443+ fn save_profile_rejects_an_oversized_display_name() {
4444+ let app = Skrepka;
4445+ let mut m = with_identity();
4446+
4447+ let _ = app.update(Event::SaveProfile {
4448+ display_name: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4449+ bio: String::new(),
4450+ photo: None,
4451+ }, &mut m);
4452+
4453+ assert!(m.outbox.is_empty(), "no broadcast queued");
4454+ assert!(m.profile.display_name.is_empty(), "local profile not updated");
4455+ assert!(m.error.as_ref().is_some_and(|e| e.contains("display name")));
4456+ }
4457+
4458+ #[test]
4459+ fn save_profile_rejects_an_oversized_bio() {
4460+ let app = Skrepka;
4461+ let mut m = with_identity();
4462+
4463+ let _ = app.update(Event::SaveProfile {
4464+ display_name: "Alice".into(),
4465+ bio: "x".repeat(protocol::MAX_BIO_LEN + 1),
4466+ photo: None,
4467+ }, &mut m);
4468+
4469+ assert!(m.outbox.is_empty(), "no broadcast queued");
4470+ assert!(m.profile.bio.is_empty(), "local profile not updated");
4471+ assert!(m.error.as_ref().is_some_and(|e| e.contains("bio")));
4472+ }
4473+
4474+ #[test]
4475+ fn save_profile_rejects_an_oversized_photo() {
4476+ let app = Skrepka;
4477+ let mut m = with_identity();
4478+
4479+ let _ = app.update(Event::SaveProfile {
4480+ display_name: "Alice".into(),
4481+ bio: String::new(),
4482+ photo: Some("x".repeat(protocol::MAX_PHOTO_LEN + 1)),
4483+ }, &mut m);
4484+
4485+ assert!(m.outbox.is_empty(), "no broadcast queued");
4486+ assert!(m.profile.photo.is_none(), "local profile not updated");
4487+ assert!(m.error.as_ref().is_some_and(|e| e.contains("photo")));
4488+ }
4489+
4490+ // -----------------------------------------------------------------------
4491+ // AddContact nickname length check
4492+ // -----------------------------------------------------------------------
4493+
4494+ #[test]
4495+ fn add_contact_rejects_an_oversized_nickname() {
4496+ let app = Skrepka;
4497+ let mut m = with_identity();
4498+ let peer = peer_hex(9);
4499+
4500+ let _ = app.update(Event::AddContact {
4501+ input: peer.clone(),
4502+ nickname: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4503+ }, &mut m);
4504+
4505+ assert!(!m.contacts.contains_key(&peer), "no contact created");
4506+ assert!(m.error.as_ref().is_some_and(|e| e.contains("nickname")));
4507+ }
4508+
4509+ // -----------------------------------------------------------------------
4510+ // FlushWatchdog unsticks a wedged send loop
4511+ // -----------------------------------------------------------------------
4512+
4513+ /// `flushing` is only cleared by `SendResult`. If the shell drops the HTTP
4514+ /// effect, the outbox is permanently stuck. The watchdog releases the guard
4515+ /// so the flush retries.
4516+ #[test]
4517+ fn the_flush_watchdog_unwedges_a_send_that_never_resolved() {
4518+ let app = Skrepka;
4519+ let mut m = with_identity();
4520+ m.token = Some("t".into());
4521+ let peer = peer_hex(9);
4522+ m.outbox.push_back(queued(&peer, "stuck"));
4523+
4524+ let _ = app.update(Event::StartFlush, &mut m);
4525+ assert!(m.flushing, "the send is in flight");
4526+ assert!(m.in_flight.is_some());
4527+
4528+ // The send never comes back. The watchdog fires, clears the stuck guard,
4529+ // and retries the flush — the message goes out again.
4530+ let mut cmd = app.update(Event::FlushWatchdog, &mut m);
4531+
4532+ assert!(m.flushing, "a fresh send is in flight (the retry)");
4533+ assert!(m.in_flight.is_some(), "in_flight is set for the retry");
4534+ let batches = sent_batches(&mut cmd);
4535+ assert_eq!(batches.len(), 1, "the message is re-sent");
4536+ }
4537+
4538+ /// A watchdog that fires after the send already completed is a no-op.
4539+ #[test]
4540+ fn a_stale_flush_watchdog_leaves_a_completed_send_alone() {
4541+ let app = Skrepka;
4542+ let mut m = with_identity();
4543+ m.token = Some("t".into());
4544+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
4545+
4546+ let _ = app.update(Event::StartFlush, &mut m);
4547+ let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
4548+ assert!(!m.flushing);
4549+ assert!(m.outbox.is_empty());
4550+
4551+ // The watchdog for the completed send fires late.
4552+ let _ = app.update(Event::FlushWatchdog, &mut m);
4553+ assert!(!m.flushing, "a completed send is not re-armed");
4554+ assert!(m.outbox.is_empty());
4555+ }
4556+
4557+ /// A late `SendResult` after the watchdog has already retried sees the
4558+ /// retry's `in_flight` and pops its items. This is correct: the retry sent
4559+ /// the same items, and a 200 means they were accepted — whether by the
4560+ /// original request or the retry is irrelevant. The items are delivered.
4561+ #[test]
4562+ fn a_late_send_result_after_the_watchdog_pops_the_retrys_items() {
4563+ let app = Skrepka;
4564+ let mut m = with_identity();
4565+ m.token = Some("t".into());
4566+ let peer = peer_hex(9);
4567+ m.outbox.push_back(queued(&peer, "stuck"));
4568+
4569+ let _ = app.update(Event::StartFlush, &mut m);
4570+ // The watchdog fires and retries the flush.
4571+ let _ = app.update(Event::FlushWatchdog, &mut m);
4572+ assert_eq!(m.outbox.len(), 1, "the item is still queued (re-sent)");
4573+
4574+ // Now the original send's result arrives late. The retry's in_flight is
4575+ // set, so a 200 pops the item — which is correct, the message was
4576+ // accepted by one of the two sends.
4577+ let _ = app.update(Event::SendResult(Ok(ok_response(200))), &mut m);
4578+ assert!(m.outbox.is_empty(), "the late 200 pops the retry's items — the message was accepted");
4579+ assert!(!m.flushing, "and the guard is released");
4580+ }
43444581}
core/src/crypto.rs+13−8
@@ -351,9 +351,14 @@ pub fn encrypt(
351351}
352352
353353/// Result of decrypting a blob: the plaintext and the sender's Ed25519 pubkey (hex).
354+///
355+/// `plaintext` is `Zeroizing<Vec<u8>>` so the decrypted message content is wiped
356+/// from memory when the value is dropped, not left in freed memory for a heap
357+/// inspection or a jetsam snapshot. `Deref` makes `&dec.plaintext` work as `&[u8]`
358+/// via auto-deref, so callers that only read the bytes need no change.
354359#[derive(Debug, Clone)]
355360pub struct Decrypted {
356− pub plaintext: Vec<u8>,
361+ pub plaintext: Zeroizing<Vec<u8>>,
357362 pub sender_hex: String,
358363}
359364
@@ -427,7 +432,7 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
427432 inner.zeroize();
428433 signed.zeroize();
429434 Ok(Decrypted {
430− plaintext,
435+ plaintext: Zeroizing::new(plaintext),
431436 sender_hex: hex::encode(sender_pub),
432437 })
433438}
@@ -478,7 +483,7 @@ mod tests {
478483 assert!(blob.len() >= MIN_BLOB_LEN);
479484
480485 let out = decrypt(&bob, &blob).unwrap();
481− assert_eq!(out.plaintext, msg);
486+ assert_eq!(out.plaintext.as_slice(), msg);
482487 assert_eq!(out.sender_hex, alice.public_key_hex());
483488 }
484489
@@ -570,7 +575,7 @@ mod tests {
570575 let bob = Identity::generate(&mut rng(2));
571576 let msg = vec![7u8; MAX_PLAINTEXT_LEN];
572577 let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
573− assert_eq!(decrypt(&bob, &blob).unwrap().plaintext, msg);
578+ assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
574579 }
575580
576581 /// A low-order ephemeral point makes every recipient derive the same shared
@@ -632,8 +637,8 @@ mod tests {
632637 assert_ne!(&blob1[..], &blob2[..], "different RNG must produce different blobs");
633638
634639 // Both must decrypt correctly.
635− assert_eq!(decrypt(&bob, &blob1).unwrap().plaintext, msg);
636− assert_eq!(decrypt(&bob, &blob2).unwrap().plaintext, msg);
640+ assert_eq!(decrypt(&bob, &blob1).unwrap().plaintext.as_slice(), msg);
641+ assert_eq!(decrypt(&bob, &blob2).unwrap().plaintext.as_slice(), msg);
637642 }
638643
639644 /// The compressed_len field must be parsed correctly so the decompressor
@@ -646,7 +651,7 @@ mod tests {
646651 // Normal round-trip.
647652 let msg = b"round trip test with some content";
648653 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), msg).unwrap();
649− assert_eq!(decrypt(&bob, &blob).unwrap().plaintext, msg);
654+ assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg);
650655
651656 // A message whose compressed size happens to produce a blob that lands
652657 // exactly on a bucket boundary (zero padding). We can't easily engineer
@@ -655,7 +660,7 @@ mod tests {
655660 for size in [1usize, 10, 100, 200, 500, 1000, 5000] {
656661 let m = vec![0xABu8; size];
657662 let b = encrypt(&mut rng(42), &alice, &bob.public_key(), &m).unwrap();
658− assert_eq!(decrypt(&bob, &b).unwrap().plaintext, m.to_vec());
663+ assert_eq!(decrypt(&bob, &b).unwrap().plaintext.as_slice(), m.as_slice());
659664 }
660665 }
661666