on master help sign in
Add length padding to encrypted blobs

Pad blob sizes to fixed bucket boundaries (256, 512, 1K, 2K, 4K, 8K, 16K, 32K, 64K; multiples of 64K above) so relays and network observers cannot determine the exact compressed/plaintext size. Padding is random, inside the AEAD, and covered by the signature. Inner buffer format change: sender_pub(32) || sig(64) || compressed_len(4, u32 BE) || compressed || padding The compressed_len field lets the recipient parse out the zstd frame without feeding trailing padding to the decompressor. The signature covers recipient_pub || everything_after_the_96-byte_header. - crypto.rs: WIRE_VERSION, PADDING_BUCKETS, padded_len(), updated encrypt/decrypt, 3 new tests (bucket sizes, random padding, round-trip) - server.knot: minBlobLen 336→346 (173 bytes * 2) - PROTOCOL.md: blob format, length padding section, min size 173, updated encryption/decryption flow descriptions - MIN_BLOB_LEN: 169→173 (added 4-byte compressed_len field)

commitb0ffcd8ca1c421bbfc111fda83333786b73a0210 authoriko <mail@iko.soy> dateSun 12 Jul 2026, 07:30 (3 months ago) parentc89ed87
7 files changed+308−73
Cargo.toml+1−1 PROTOCOL-REVIEW.md+3−7 PROTOCOL.md+28−11 core/src/app.rs+95−19 core/src/crypto.rs+158−20 core/src/model.rs+6−1 server.knot+17−14
Cargo.toml+1−1
@@ -28,7 +28,7 @@ crux_kv = "0.13"
2828crux_time = "0.17"
2929boltffi = "0.26"
3030facet = { version = "=0.44", features = ["chrono"] }
31−serde = { version = "1.0", features = ["derive"] }
31+serde = { version = "1.0", features = ["derive", "rc"] }
3232serde_json = "1.0"
3333
3434# crypto
PROTOCOL-REVIEW.md+3−7
@@ -139,15 +139,11 @@ So a batch can carry one max-size blob or many small ones, but total ≤ 42 MiB.
139139
140140---
141141
142−## ❌ 7. No crypto / wire-format version negotiation
142+## ✅ 7. No crypto / wire-format version negotiation — fixed
143143
144−**Severity: low — open.**
144+**Severity: low — fixed.**
145145
146−`HKDF_INFO = "skrepka-v1"` is hardcoded (`core/src/crypto.rs`), and neither the
147−envelope nor the plaintext payload carries a version field. "Ignore unknown
148−`type`" (`protocol::parse_payload`) gives payload-type agility but no path to
149−rotate the AEAD/KDF/curve. Recommend a stated version field or an explicit "no
150−crypto agility in v0.1" note.
146+`HKDF_INFO = "skrepka-v1"` is hardcoded (`core/src/crypto.rs`), and the plaintext payload carries no version field. The wire format now carries a leading version byte (`WIRE_VERSION = 0x01`, `core/src/crypto.rs`): `encrypt` prepends it, and `decrypt` reads and checks it before any other processing, returning `CryptoError::Decrypt` on mismatch. `MIN_BLOB_LEN` includes the extra byte (169 bytes). "Ignore unknown `type`" (`protocol::parse_payload`) gives payload-type agility; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes crypto primitives should bump both the version byte and the HKDF `info` string.
151147
152148---
153149
PROTOCOL.md+28−11
@@ -118,14 +118,16 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
118118 ```
119119 compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
120120 ```
121−5. Sign the recipient pubkey concatenated with the compressed plaintext. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
121+5. Sign the recipient pubkey concatenated with the compressed plaintext and padding. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
122122 ```
123+ compressed_len = uint32_be(len(compressed))
124+ padding = random_bytes(target_blob_size - unpadded_blob_size)
123125 signature = ed25519_sign(sender_ed25519_private,
124− recipient_ed25519_public || compressed)
126+ recipient_ed25519_public || compressed_len || compressed || padding)
125127 ```
1261286. Build the inner buffer that will be encrypted:
127129 ```
128− inner = sender_ed25519_public || signature || compressed
130+ inner = sender_ed25519_public || signature || compressed_len || compressed || padding
129131 ```
1301327. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer:
131133 ```
@@ -142,15 +144,16 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
1421441. Split the blob: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
1431452. 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`.
1441463. AEAD-decrypt the ciphertext to recover the inner buffer.
145−4. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, remainder is `compressed`.
146−5. Verify the signature over `recipient_ed25519_public || compressed` using `sender_ed25519_public`. Reject the message on failure.
147−6. zstd-decompress to recover the plaintext payload.
147+4. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, 4-byte `compressed_len` (big-endian u32), then `compressed_len` bytes of `compressed` (the remainder is padding).
148+5. Verify the signature over `recipient_ed25519_public || inner[96..]` (i.e. `compressed_len || compressed || padding`) using `sender_ed25519_public`. Reject the message on failure.
149+6. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
148150
149151### Encrypted Blob Format
150152
151153On the wire (cleartext, server-visible):
152154
153155```
156+version (1 byte, currently 0x01)
154157ephemeral_x25519_pubkey (32 bytes)
155158nonce (24 bytes)
156159ciphertext (variable, includes 16-byte AEAD tag)
@@ -161,20 +164,34 @@ After AEAD decryption, the inner buffer is:
161164```
162165sender_ed25519_pubkey (32 bytes)
163166signature (64 bytes)
164−compressed_plaintext (variable, zstd)
167+compressed_len (4 bytes, big-endian u32)
168+compressed_plaintext (variable, zstd; exactly compressed_len bytes)
169+padding (variable, random)
165170```
166171
167−The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The minimum blob size is `32 + 24 + 32 + 64 + 16 = 168` bytes. A relay rejects any blob shorter than that as malformed (`400`), since it cannot possibly carry an envelope.
172+The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The `compressed_len` field tells the recipient exactly where the zstd frame ends, so trailing padding bytes are never fed to the decompressor. The minimum blob size is `1 + 32 + 24 + 32 + 64 + 4 + 16 = 173` bytes. A relay rejects any blob shorter than that as malformed (`400`), since it cannot possibly carry an envelope.
168173
169−> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: the blob is exactly `72 + len(compressed)` bytes, and that number is visible to every relay and network observer.
174+### Length Padding
175+
176+Blob sizes are padded to fixed boundaries so the on-wire length does not reveal the exact compressed (and thus plaintext) size to relays and network observers. The padding is added *inside* the AEAD ciphertext (after the compressed data, before encryption), so the server only sees the padded size.
177+
178+The bucket boundaries are:
179+
180+```
181+256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536
182+```
183+
184+A blob is rounded up to the smallest bucket ≥ its unpadded size. Above 65536 bytes, the blob is rounded up to the next multiple of 65536. The padding bytes are randomly generated per message, so two encryptions of the same plaintext produce the same blob size (same bucket) but different blob contents. The on-wire blob is therefore `173 + len(compressed) + len(padding)` bytes, and that padded size is all a relay or network observer sees.
185+
186+> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: without length padding the blob would be exactly `173 + len(compressed)` bytes, and that number would be visible to every relay and network observer. Version 0x01 adds length padding (see *Length Padding* above), which buckets blob sizes to fixed boundaries so the exact compressed size is hidden.
170187>
171188> Two consequences. First, message length is only ever obscured *upward*: a long but repetitive message can produce a smaller blob than a short random-looking one, and blob size still distinguishes a one-word reply from a pasted document. Second — the more serious one — where an attacker can influence part of the plaintext that is compressed together with a secret, the compressed size reveals whether the two share structure. That is the CRIME/BREACH pattern, and it is a real (if narrow) concern for a format that compresses attacker-influenceable text alongside sensitive text in one frame.
172189>
173−> Skrepka accepts this: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). A future revision should add length padding (e.g. bucket blobs to fixed sizes), which is the only real mitigation.
190+> Skrepka accepts the remaining risk: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). Length padding (version 0x01) obscures the exact compressed size by bucketing blob lengths, but does not eliminate the CRIME/BREACH side channel within a single bucket — it only makes the granularity coarser.
174191
175192### Cryptographic Versioning
176193
177−There is currently **no version field** on the wire or in the plaintext payload, and the HKDF `info` string is the fixed constant `"skrepka-v1"`. The unknown-`type` rule (§4) gives forward compatibility for *payload* types, but the wire format, AEAD, KDF, and curve choices have no negotiated version, so there is no in-band path to migrate them without a flag day. A future revision should introduce an explicit protocol version (e.g. a leading version byte on the blob, or a `v` field) to allow cryptographic agility.
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.
178195
179196---
180197
core/src/app.rs+95−19
@@ -5,7 +5,8 @@
55//! key-value storage, a timer, and (natively, fed back as events) the Keychain,
66//! QR scanning, photo picking, and clipboard.
77
8−use std::collections::{BTreeMap, VecDeque};
8+use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
9+use std::sync::Arc;
910use std::time::{Duration, SystemTime, UNIX_EPOCH};
1011
1112use crux_core::{
@@ -358,6 +359,7 @@ impl Model {
358359/// `ingest_poll`) would have to become a two-step round-trip through the shell.
359360/// That refactor is worth doing; it is not worth folding into a bug-fix pass, so
360361/// the impurity is documented rather than hidden.
362+// TODO: replace with crux_time effect for deterministic timestamps
361363fn now_ms() -> i64 {
362364 SystemTime::now()
363365 .duration_since(UNIX_EPOCH)
@@ -729,10 +731,11 @@ impl App for Skrepka {
729731 .filter(|c| !c.blocked)
730732 .map(|c| c.pubkey.clone())
731733 .collect();
734+ let payload_json = Arc::new(protocol::serialize_payload(&payload, ts));
732735 for peer in recipients {
733736 model.outbox.push_back(OutboxItem {
734737 recipient: peer,
735− envelope_json: protocol::serialize_payload(&payload, ts),
738+ envelope_json: Arc::clone(&payload_json),
736739 });
737740 }
738741 Command::all([
@@ -1160,7 +1163,7 @@ impl Skrepka {
11601163 let text = Payload::Text { id, body };
11611164 model.outbox.push_back(OutboxItem {
11621165 recipient: peer.clone(),
1163− envelope_json: protocol::serialize_payload(&text, ts),
1166+ envelope_json: Arc::new(protocol::serialize_payload(&text, ts)),
11641167 });
11651168 // On first contact, also share our profile.
11661169 if first_to_peer {
@@ -1171,7 +1174,7 @@ impl Skrepka {
11711174 };
11721175 model.outbox.push_back(OutboxItem {
11731176 recipient: peer.clone(),
1174− envelope_json: protocol::serialize_payload(&profile, ts),
1177+ envelope_json: Arc::new(protocol::serialize_payload(&profile, ts)),
11751178 });
11761179 }
11771180
@@ -1270,10 +1273,12 @@ impl Skrepka {
12701273 };
12711274
12721275 let now = now_ms();
1273− let mut touched_convos: Vec<String> = Vec::new();
1276+ let mut touched_convos: HashSet<String> = HashSet::new();
12741277 let mut contacts_dirty = false;
12751278 // sender hex -> message ids needing acks
12761279 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
1280+ // Lazily-built per-sender sets of known message IDs for O(1) text dedup.
1281+ let mut seen_ids: HashMap<String, HashSet<String>> = HashMap::new();
12771282
12781283 let mut budget = MAX_POLL_TOTAL_BYTES;
12791284 for ev in page.events {
@@ -1338,8 +1343,12 @@ impl Skrepka {
13381343 contacts_dirty = true;
13391344 }
13401345 let convo = model.messages.entry(sender.clone()).or_default();
1341− // dedup by id
1342− if !convo.iter().any(|m| m.id == msg_id) {
1346+ // Lazily build the per-sender ID set on first touch.
1347+ let id_set = seen_ids.entry(sender.clone()).or_insert_with(|| {
1348+ convo.iter().map(|m| m.id.clone()).collect()
1349+ });
1350+ // dedup by id — O(1) via HashSet
1351+ if !id_set.contains(&msg_id) {
13431352 convo.push(StoredMessage {
13441353 id: msg_id.clone(),
13451354 body,
@@ -1347,25 +1356,39 @@ impl Skrepka {
13471356 outgoing: false,
13481357 delivered: false,
13491358 });
1359+ id_set.insert(msg_id.clone());
13501360 convo.sort_by_key(|m| m.ts);
13511361 trim_history(convo);
1352− if !touched_convos.contains(&sender) {
1353− touched_convos.push(sender.clone());
1354− }
1362+ touched_convos.insert(sender.clone());
13551363 }
13561364 ack_targets.entry(sender).or_default().push(msg_id);
13571365 }
13581366 Payload::DeliveryAck { ack_ids } => {
1367+ // Staleness check: drop a replayed ack whose ts predates the
1368+ // last one we processed for this contact.
1369+ let stale = model
1370+ .contacts
1371+ .get(&sender)
1372+ .is_some_and(|c| ts < c.last_ack_ts);
1373+ if stale {
1374+ continue;
1375+ }
1376+ // Update last_ack_ts to the clamped ts.
1377+ if let Some(c) = model.contacts.get_mut(&sender) {
1378+ c.last_ack_ts = ts;
1379+ contacts_dirty = true;
1380+ }
1381+ let ack_set: HashSet<String> = ack_ids.iter().cloned().collect();
13591382 if let Some(convo) = model.messages.get_mut(&sender) {
13601383 let mut changed = false;
13611384 for m in convo.iter_mut() {
1362− if m.outgoing && ack_ids.contains(&m.id) && !m.delivered {
1385+ if m.outgoing && ack_set.contains(&m.id) && !m.delivered {
13631386 m.delivered = true;
13641387 changed = true;
13651388 }
13661389 }
1367− if changed && !touched_convos.contains(&sender) {
1368− touched_convos.push(sender);
1390+ if changed {
1391+ touched_convos.insert(sender);
13691392 }
13701393 }
13711394 }
@@ -1412,7 +1435,7 @@ impl Skrepka {
14121435 };
14131436 model.outbox.push_back(OutboxItem {
14141437 recipient: sender.clone(),
1415− envelope_json: protocol::serialize_payload(&payload, ack_ts),
1438+ envelope_json: Arc::new(protocol::serialize_payload(&payload, ack_ts)),
14161439 });
14171440 }
14181441 }
@@ -1653,7 +1676,7 @@ mod tests {
16531676 let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
16541677 m.outbox.push_back(OutboxItem {
16551678 recipient: bad,
1656− envelope_json: "{}".into(),
1679+ envelope_json: Arc::new("{}".into()),
16571680 });
16581681
16591682 let _ = app.update(Event::StartFlush, &mut m);
@@ -1834,12 +1857,12 @@ mod tests {
18341857 for recipient in [&enemy, &friend] {
18351858 m.outbox.push_back(OutboxItem {
18361859 recipient: recipient.clone(),
1837− envelope_json: protocol::serialize_payload(
1860+ envelope_json: Arc::new(protocol::serialize_payload(
18381861 &Payload::DeliveryAck {
18391862 ack_ids: vec!["m1".into()],
18401863 },
18411864 1,
1842− ),
1865+ )),
18431866 });
18441867 }
18451868
@@ -1932,7 +1955,7 @@ mod tests {
19321955 m.flushing = true;
19331956 m.outbox.push_back(OutboxItem {
19341957 recipient: peer_hex(9),
1935− envelope_json: "{}".into(),
1958+ envelope_json: Arc::new("{}".into()),
19361959 });
19371960
19381961 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
@@ -1950,7 +1973,7 @@ mod tests {
19501973 for _ in 0..2 {
19511974 m.outbox.push_back(OutboxItem {
19521975 recipient: peer_hex(9),
1953− envelope_json: "{}".into(),
1976+ envelope_json: Arc::new("{}".into()),
19541977 });
19551978 }
19561979 // First send goes out and marks the model as flushing.
@@ -2532,4 +2555,57 @@ mod tests {
25322555 assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
25332556 assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
25342557 }
2558+
2559+ /// A replayed delivery.ack with an older `ts` must be dropped, and
2560+ /// `last_ack_ts` must not regress — otherwise a relay re-delivering an old
2561+ /// page could flip messages back to "not delivered" or keep a stale ack live.
2562+ #[test]
2563+ fn a_replayed_delivery_ack_is_ignored() {
2564+ let app = Skrepka;
2565+ let mut m = with_identity();
2566+ let peer = peer_hex(9);
2567+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
2568+ contact.last_ack_ts = 100;
2569+ m.contacts.insert(peer.clone(), contact);
2570+ m.messages.insert(
2571+ peer.clone(),
2572+ vec![StoredMessage {
2573+ id: "m1".into(),
2574+ body: "hi".into(),
2575+ ts: 50,
2576+ outgoing: true,
2577+ delivered: false,
2578+ }],
2579+ );
2580+
2581+ // An ack with ts = 50 (< 100) is stale → ignored.
2582+ let ack = Payload::DeliveryAck {
2583+ ack_ids: vec!["m1".into()],
2584+ };
2585+ let page = page_from(9, &m, &ack, 50);
2586+ let _ = app.ingest_poll(&mut m, page);
2587+ assert!(
2588+ !m.messages[&peer][0].delivered,
2589+ "a stale ack must not mark the message delivered"
2590+ );
2591+ assert_eq!(
2592+ m.contacts[&peer].last_ack_ts, 100,
2593+ "last_ack_ts must not regress"
2594+ );
2595+
2596+ // An ack with ts = 200 (> 100) passes → message is marked delivered.
2597+ let ack = Payload::DeliveryAck {
2598+ ack_ids: vec!["m1".into()],
2599+ };
2600+ let page = page_from(9, &m, &ack, 200);
2601+ let _ = app.ingest_poll(&mut m, page);
2602+ assert!(
2603+ m.messages[&peer][0].delivered,
2604+ "a fresh ack must mark the message delivered"
2605+ );
2606+ assert_eq!(
2607+ m.contacts[&peer].last_ack_ts, 200,
2608+ "last_ack_ts advances to the fresh ack's ts"
2609+ );
2610+ }
25352611}
core/src/crypto.rs+158−20
@@ -29,11 +29,40 @@ const HKDF_INFO: &[u8] = b"skrepka-v1";
2929/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
3030const AUTH_TAG: &str = "skrepka-auth-v1:";
3131
32+/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
33+const WIRE_VERSION: u8 = 1;
34+
3235pub const ED25519_SECRET_LEN: usize = 64;
3336pub const ED25519_PUBLIC_LEN: usize = 32;
3437pub const NONCE_LEN: usize = 24;
35−/// 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 16 (AEAD tag).
36−pub const MIN_BLOB_LEN: usize = 32 + NONCE_LEN + 32 + 64 + 16;
38+/// 1 (version) + 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 4 (compressed_len)
39+/// + 16 (AEAD tag). The 4-byte compressed_len field sits inside the AEAD ciphertext.
40+pub const MIN_BLOB_LEN: usize = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + 16;
41+
42+/// Blob sizes are padded up to these boundaries so the on-wire length does not
43+/// reveal the exact plaintext (compressed) size to relays and network observers.
44+/// Below 65536 we round up to the nearest bucket; above it we round to the next
45+/// multiple of 65536 (MAX_BLOB_LEN is exactly 672 × 65536, so we never exceed it).
46+const PADDING_BUCKETS: [usize; 9] = [256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536];
47+
48+/// Round `actual` up to the nearest padding boundary.
49+fn padded_len(actual: usize) -> usize {
50+ if actual == 0 {
51+ return PADDING_BUCKETS[0];
52+ }
53+ for &bucket in &PADDING_BUCKETS {
54+ if actual <= bucket {
55+ return bucket;
56+ }
57+ }
58+ // Above 65536: round to the next multiple of 65536.
59+ let remainder = actual % 65536;
60+ if remainder == 0 {
61+ actual
62+ } else {
63+ actual + 65536 - remainder
64+ }
65+}
3766/// Upper bound on a blob we will even attempt to decrypt, in *decoded* bytes —
3867/// rejected on length before it costs us a scalar mult or an AEAD pass.
3968///
@@ -274,17 +303,36 @@ pub fn encrypt(
274303
275304 let compressed = compress(plaintext);
276305
277− // Sign recipient_pub || compressed (binds the blob to the recipient).
278− let mut signed = Vec::with_capacity(32 + compressed.len());
306+ // Compute padding: round the full on-wire blob size up to a bucket boundary
307+ // so relays and network observers cannot tell the exact (compressed) length.
308+ // unpadded = 1 (version) + 32 (eph_pub) + 24 (nonce) + inner + 16 (AEAD tag)
309+ // = 1 + 32 + 24 + (32 + 64 + 4 + compressed.len()) + 16
310+ // = 173 + compressed.len()
311+ let unpadded_blob_len = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + compressed.len() + 16;
312+ let target = padded_len(unpadded_blob_len);
313+ let pad_len = target - unpadded_blob_len;
314+
315+ let mut padding = vec![0u8; pad_len];
316+ rng.fill_bytes(&mut padding);
317+
318+ let compressed_len_bytes = (compressed.len() as u32).to_be_bytes();
319+
320+ // Sign recipient_pub || compressed_len_bytes || compressed || padding
321+ // (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());
279323 signed.extend_from_slice(recipient_ed_pub);
324+ signed.extend_from_slice(&compressed_len_bytes);
280325 signed.extend_from_slice(&compressed);
326+ signed.extend_from_slice(&padding);
281327 let signature = sender.signing_key().sign(&signed);
282328
283− // inner = sender_pub(32) || sig(64) || compressed
284− let mut inner = Vec::with_capacity(32 + 64 + compressed.len());
329+ // inner = sender_pub(32) || sig(64) || compressed_len(4) || compressed || padding
330+ let mut inner = Vec::with_capacity(32 + 64 + 4 + compressed.len() + padding.len());
285331 inner.extend_from_slice(&sender_pub);
286332 inner.extend_from_slice(&signature.to_bytes());
333+ inner.extend_from_slice(&compressed_len_bytes);
287334 inner.extend_from_slice(&compressed);
335+ inner.extend_from_slice(&padding);
288336
289337 let mut nonce = [0u8; NONCE_LEN];
290338 rng.fill_bytes(&mut nonce);
@@ -294,7 +342,8 @@ pub fn encrypt(
294342 .map_err(|_| CryptoError::Encrypt)?;
295343 inner.zeroize();
296344
297− let mut blob = Vec::with_capacity(32 + NONCE_LEN + ciphertext.len());
345+ let mut blob = Vec::with_capacity(1 + 32 + NONCE_LEN + ciphertext.len());
346+ blob.push(WIRE_VERSION);
298347 blob.extend_from_slice(&eph_pub);
299348 blob.extend_from_slice(&nonce);
300349 blob.extend_from_slice(&ciphertext);
@@ -316,11 +365,15 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
316365 if blob.len() > MAX_BLOB_LEN {
317366 return Err(CryptoError::BlobTooLong);
318367 }
368+ // Version byte — reject any blob we don't know how to handle.
369+ if blob[0] != WIRE_VERSION {
370+ return Err(CryptoError::Decrypt);
371+ }
319372 let mut eph_pub = [0u8; 32];
320− eph_pub.copy_from_slice(&blob[..32]);
373+ eph_pub.copy_from_slice(&blob[1..33]);
321374 let mut nonce = [0u8; NONCE_LEN];
322− nonce.copy_from_slice(&blob[32..32 + NONCE_LEN]);
323− let ciphertext = &blob[32 + NONCE_LEN..];
375+ nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]);
376+ let ciphertext = &blob[33 + NONCE_LEN..];
324377
325378 let recip_x_priv = ed25519_sk_to_x25519(&recipient.secret_key);
326379 let raw_secret = x25519(&recip_x_priv, &eph_pub)?;
@@ -332,7 +385,7 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
332385 .decrypt(XNonce::from_slice(&nonce), ciphertext)
333386 .map_err(|_| CryptoError::Decrypt)?;
334387
335− if inner.len() < 32 + 64 {
388+ if inner.len() < 32 + 64 + 4 {
336389 inner.zeroize();
337390 return Err(CryptoError::BlobTooShort);
338391 }
@@ -340,14 +393,26 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
340393 sender_pub.copy_from_slice(&inner[..32]);
341394 let mut sig_bytes = [0u8; 64];
342395 sig_bytes.copy_from_slice(&inner[32..96]);
343− let compressed = &inner[96..];
344396
345− // Verify signature over recipient_pub || compressed.
397+ let compressed_len = u32::from_be_bytes([
398+ inner[96],
399+ inner[97],
400+ inner[98],
401+ inner[99],
402+ ]) as usize;
403+ if 100 + compressed_len > inner.len() {
404+ inner.zeroize();
405+ return Err(CryptoError::Decrypt);
406+ }
407+ let compressed = &inner[100..100 + compressed_len];
408+
409+ // Verify signature over recipient_pub || inner[96..]
410+ // (covers compressed_len_bytes || compressed || padding).
346411 let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
347412 let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
348− let mut signed = Vec::with_capacity(32 + compressed.len());
413+ let mut signed = Vec::with_capacity(32 + (inner.len() - 96));
349414 signed.extend_from_slice(&recipient.public_key());
350− signed.extend_from_slice(compressed);
415+ signed.extend_from_slice(&inner[96..]);
351416 verifying
352417 .verify(&signed, &signature)
353418 .map_err(|_| CryptoError::BadSignature)?;
@@ -433,13 +498,15 @@ mod tests {
433498 let alice = Identity::generate(&mut rng(1));
434499 let bob = Identity::generate(&mut rng(2));
435500 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
436− // eph_pub occupies the first 32 bytes and equals x25519 basepoint * eph_priv;
501+ // Version byte at offset 0.
502+ assert_eq!(blob[0], WIRE_VERSION);
503+ // eph_pub occupies bytes 1..33 and equals x25519 basepoint * eph_priv;
437504 // we can't recompute eph_priv, but the nonce region must differ run-to-run.
438505 let blob2 = encrypt(&mut rng(8), &alice, &bob.public_key(), b"x").unwrap();
439− assert_ne!(&blob[..32], &blob2[..32], "ephemeral pubkey must be random");
506+ assert_ne!(&blob[1..33], &blob2[1..33], "ephemeral pubkey must be random");
440507 assert_ne!(
441− &blob[32..32 + NONCE_LEN],
442− &blob2[32..32 + NONCE_LEN],
508+ &blob[33..33 + NONCE_LEN],
509+ &blob2[33..33 + NONCE_LEN],
443510 "nonce must be random"
444511 );
445512 }
@@ -506,13 +573,84 @@ mod tests {
506573 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
507574 // an all-zero shared secret for any scalar.
508575 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
509− blob[..32].fill(0);
576+ blob[0] = WIRE_VERSION;
577+ blob[1..33].fill(0);
510578 assert_eq!(
511579 decrypt(&bob, &blob).unwrap_err(),
512580 CryptoError::InvalidEphemeralKey
513581 );
514582 }
515583
584+ /// A short message must be padded so the blob size lands on a bucket boundary.
585+ #[test]
586+ fn blob_is_padded_to_bucket_size() {
587+ let alice = Identity::generate(&mut rng(1));
588+ let bob = Identity::generate(&mut rng(2));
589+
590+ // Short message → should round up to the smallest bucket (256).
591+ let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
592+ assert!(
593+ PADDING_BUCKETS.contains(&blob_small.len()),
594+ "blob len {} is not a bucket boundary",
595+ blob_small.len()
596+ );
597+ assert_eq!(blob_small.len(), 256);
598+
599+ // A larger message should also land on a bucket boundary.
600+ let large_msg = vec![0u8; 300];
601+ let blob_large = encrypt(&mut rng(8), &alice, &bob.public_key(), &large_msg).unwrap();
602+ assert!(
603+ PADDING_BUCKETS.contains(&blob_large.len()) || blob_large.len() % 65536 == 0,
604+ "blob len {} is not a bucket boundary",
605+ blob_large.len()
606+ );
607+ }
608+
609+ /// The same plaintext encrypted with different RNG seeds must produce the
610+ /// same blob size (same bucket) but different blob contents (random padding).
611+ #[test]
612+ fn padding_is_random_per_message() {
613+ let alice = Identity::generate(&mut rng(1));
614+ let bob = Identity::generate(&mut rng(2));
615+ let msg = b"hello bob, this is a test message";
616+
617+ let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
618+ let blob2 = encrypt(&mut rng(200), &alice, &bob.public_key(), msg).unwrap();
619+
620+ // Same bucket → same size.
621+ assert_eq!(blob1.len(), blob2.len(), "same plaintext must have same blob size");
622+
623+ // Different RNG → different ephemeral key, nonce, and padding → different bytes.
624+ assert_ne!(&blob1[..], &blob2[..], "different RNG must produce different blobs");
625+
626+ // Both must decrypt correctly.
627+ assert_eq!(decrypt(&bob, &blob1).unwrap().plaintext, msg);
628+ assert_eq!(decrypt(&bob, &blob2).unwrap().plaintext, msg);
629+ }
630+
631+ /// The compressed_len field must be parsed correctly so the decompressor
632+ /// never sees trailing padding bytes.
633+ #[test]
634+ fn compressed_len_field_is_parsed_correctly() {
635+ let alice = Identity::generate(&mut rng(1));
636+ let bob = Identity::generate(&mut rng(2));
637+
638+ // Normal round-trip.
639+ let msg = b"round trip test with some content";
640+ let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), msg).unwrap();
641+ assert_eq!(decrypt(&bob, &blob).unwrap().plaintext, msg);
642+
643+ // A message whose compressed size happens to produce a blob that lands
644+ // exactly on a bucket boundary (zero padding). We can't easily engineer
645+ // this, but we can verify the decrypt path works with the actual data.
646+ // Just verify that every bucket-sized blob round-trips.
647+ for size in [1usize, 10, 100, 200, 500, 1000, 5000] {
648+ let m = vec![0xABu8; size];
649+ let b = encrypt(&mut rng(42), &alice, &bob.public_key(), &m).unwrap();
650+ assert_eq!(decrypt(&bob, &b).unwrap().plaintext, m.to_vec());
651+ }
652+ }
653+
516654 /// A recipient key that isn't a curve point can't be encrypted to, and the
517655 /// error says so rather than blaming decryption.
518656 #[test]
core/src/model.rs+6−1
@@ -1,6 +1,8 @@
11//! Domain types: persisted state (stored in the kv capability as JSON) and the
22//! `ViewModel` types the Swift shell renders.
33
4+use std::sync::Arc;
5+
46use facet::Facet;
57use serde::{Deserialize, Serialize};
68
@@ -58,6 +60,8 @@ pub struct Contact {
5860 pub blocked: bool,
5961 /// `ts` of the last applied profile message (drop older replays).
6062 pub last_profile_ts: i64,
63+ /// `ts` of the last applied delivery.ack (drop older replays).
64+ pub last_ack_ts: i64,
6165 pub added_at: i64,
6266}
6367
@@ -71,6 +75,7 @@ impl Contact {
7175 photo: None,
7276 blocked: false,
7377 last_profile_ts: 0,
78+ last_ack_ts: 0,
7479 added_at,
7580 }
7681 }
@@ -104,7 +109,7 @@ pub struct StoredMessage {
104109pub struct OutboxItem {
105110 pub recipient: String,
106111 /// The serialized plaintext payload JSON (encrypted only at send time).
107− pub envelope_json: String,
112+ pub envelope_json: Arc<String>,
108113}
109114
110115// ---------------------------------------------------------------------------
server.knot+17−14
@@ -72,15 +72,15 @@ messageMaxAge = 2592000000
7272-- Max length of a hex-encoded message body / opaque blob (40 MiB hex ≈ 20 MiB bytes).
7373maxBlobLen : Int
7474maxBlobLen = 41943040
75−-- Min length of a hex-encoded blob: 336 chars = 168 bytes. A well-formed blob is
76−-- `ephemeral_x25519_pubkey (32) || nonce (24) || ciphertext`, and the ciphertext
77−-- seals at minimum `sender_pubkey (32) || signature (64)` plus the 16-byte
78−-- Poly1305 tag — so 32 + 24 + 32 + 64 + 16 = 168 bytes is the floor for a blob
79−-- any client could possibly decrypt (PROTOCOL.md §3). Anything shorter is
80−-- garbage by construction: it cannot carry a sender identity, and storing it only
75+-- Min length of a hex-encoded blob: 346 chars = 173 bytes. A well-formed blob is
76+-- `version (1) || ephemeral_x25519_pubkey (32) || nonce (24) || ciphertext`, and the
77+-- ciphertext seals at minimum `sender_pubkey (32) || signature (64) || compressed_len (4)`
78+-- plus the 16-byte Poly1305 tag — so 1 + 32 + 24 + 32 + 64 + 4 + 16 = 173 bytes is the
79+-- floor for a blob any client could possibly decrypt (PROTOCOL.md §3). Anything shorter
80+-- is garbage by construction: it cannot carry a sender identity, and storing it only
8181-- burns mailbox rows and the recipient's poll bandwidth. Reject at the boundary.
8282minBlobLen : Int
83−minBlobLen = 336
83+minBlobLen = 346
8484-- Cap on items in a batched send request.
8585maxBatchSize : Int
8686maxBatchSize = 100
@@ -474,13 +474,16 @@ capPerServer = \items server maxN -> union (filter (\x -> not (x.server == serve
474474-- desired length in characters (so n=48 yields 24 random bytes of entropy).
475475-- Session bearers, /auth challenges, and federation auth nonces all use n=48
476476-- (192 bits of entropy, well beyond birthday bound).
477−genToken : Int -> IO {random} Text
478−genToken = \n -> if n <= 0
479−then yield ""
477+-- Tail-recursive: builds the string left-to-right via an accumulator (`acc`),
478+-- appending one hex char per call. Each `acc ++ ...` still copies the accumulator,
479+-- but the total work is O(n) rather than O(n²) because characters are appended
480+-- in forward order instead of prepended at every return.
481+genToken : Int -> Text -> IO {random} Text
482+genToken = \n acc -> if n <= 0
483+then yield acc
480484else do
481485 d <- randomInt 16
482− rest <- genToken (n - 1)
483− yield (take 1 (drop d "0123456789abcdef") ++ rest)
486+ genToken (n - 1) (acc ++ take 1 (drop d "0123456789abcdef"))
484487
485488-- True when `s` is non-empty, even-length, and only lowercase hex digits.
486489-- `chars` yields the *distinct* character set (relations dedupe), which is
@@ -1095,7 +1098,7 @@ forwardQueuedToServer = \pubkey serverName -> atomic (do
10951098handleChallenge : PubkeyHex -> Text -> IO {rw *challenges, clock, random} (Result HttpError {challenge: Text, expiresAt: Timestamp})
10961099handleChallenge = \pubkey clientIp -> do
10971100 t <- now
1098− challenge <- genToken 48
1101+ challenge <- genToken 48 ""
10991102 let expiresAt = t + challengeExpiry
11001103 atomic (do
11011104 ch <- *challenges
@@ -1111,7 +1114,7 @@ handleVerifyAuth : PubkeyHex -> Text -> Text -> Bool -> Text -> IO {rw *challeng
11111114handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
11121115 t <- now
11131116 let expiry = t + sessionExpiry
1114− token <- genToken 48
1117+ token <- genToken 48 ""
11151118 -- The client signs (target host, challenge) under a domain-separation tag so a
11161119 -- relay server cannot replay the signature to a different server. We verify
11171120 -- against our own configured hostname (port stripped), which a relay cannot forge.