on master help sign in
Add length padding to encrypted blobs
b0ffcd8iko
Cargo.toml+1−1
@@ -28,7 +28,7 @@ crux_kv = "0.13"
28crux_time = "0.17"
28crux_time = "0.17"
29boltffi = "0.26"
29boltffi = "0.26"
30facet = { version = "=0.44", features = ["chrono"] }
30facet = { version = "=0.44", features = ["chrono"] }
31−serde = { version = "1.0", features = ["derive"] }
31+serde = { version = "1.0", features = ["derive", "rc"] }
32serde_json = "1.0"
32serde_json = "1.0"
33
33
34# crypto
34# 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.
139
139
140---
140---
141
141
142−## ❌ 7. No crypto / wire-format version negotiation
142+## ✅ 7. No crypto / wire-format version negotiation — fixed
143
143
144−**Severity: low — open.**
144+**Severity: low — fixed.**
145
145
146−`HKDF_INFO = "skrepka-v1"` is hardcoded (`core/src/crypto.rs`), and neither the
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.
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.
151
147
152---
148---
153
149
PROTOCOL.md+28−11
@@ -118,14 +118,16 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
118 ```
118 ```
119 compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
119 compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
120 ```
120 ```
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:
122 ```
122 ```
123+ compressed_len = uint32_be(len(compressed))
124+ padding = random_bytes(target_blob_size - unpadded_blob_size)
123 signature = ed25519_sign(sender_ed25519_private,
125 signature = ed25519_sign(sender_ed25519_private,
124− recipient_ed25519_public || compressed)
126+ recipient_ed25519_public || compressed_len || compressed || padding)
125 ```
127 ```
1266. Build the inner buffer that will be encrypted:
1286. Build the inner buffer that will be encrypted:
127 ```
129 ```
128− inner = sender_ed25519_public || signature || compressed
130+ inner = sender_ed25519_public || signature || compressed_len || compressed || padding
129 ```
131 ```
1307. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer:
1327. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer:
131 ```
133 ```
@@ -142,15 +144,16 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
1421. Split the blob: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
1441. Split the blob: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
1432. 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`.
1452. 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`.
1443. AEAD-decrypt the ciphertext to recover the inner buffer.
1463. AEAD-decrypt the ciphertext to recover the inner buffer.
145−4. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, remainder is `compressed`.
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).
146−5. Verify the signature over `recipient_ed25519_public || compressed` using `sender_ed25519_public`. Reject the message on failure.
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.
147−6. zstd-decompress to recover the plaintext payload.
149+6. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
148
150
149### Encrypted Blob Format
151### Encrypted Blob Format
150
152
151On the wire (cleartext, server-visible):
153On the wire (cleartext, server-visible):
152
154
153```
155```
156+version (1 byte, currently 0x01)
154ephemeral_x25519_pubkey (32 bytes)
157ephemeral_x25519_pubkey (32 bytes)
155nonce (24 bytes)
158nonce (24 bytes)
156ciphertext (variable, includes 16-byte AEAD tag)
159ciphertext (variable, includes 16-byte AEAD tag)
@@ -161,20 +164,34 @@ After AEAD decryption, the inner buffer is:
161```
164```
162sender_ed25519_pubkey (32 bytes)
165sender_ed25519_pubkey (32 bytes)
163signature (64 bytes)
166signature (64 bytes)
167+compressed_len (4 bytes, big-endian u32)
164−compressed_plaintext (variable, zstd)
168+compressed_plaintext (variable, zstd; exactly compressed_len bytes)
169+padding (variable, random)
165```
170```
166
171
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.
168
173
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+
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.
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.
170>
187>
171> 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.
188> 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.
172>
189>
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.
174
191
175### Cryptographic Versioning
192### Cryptographic Versioning
176
193
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.
178
195
179---
196---
180
197
core/src/app.rs+95−19
@@ -5,7 +5,8 @@
5//! key-value storage, a timer, and (natively, fed back as events) the Keychain,
5//! key-value storage, a timer, and (natively, fed back as events) the Keychain,
6//! QR scanning, photo picking, and clipboard.
6//! QR scanning, photo picking, and clipboard.
7
7
8−use std::collections::{BTreeMap, VecDeque};
8+use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
9+use std::sync::Arc;
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11
11use crux_core::{
12use crux_core::{
@@ -358,6 +359,7 @@ impl Model {
358/// `ingest_poll`) would have to become a two-step round-trip through the shell.
359/// `ingest_poll`) would have to become a two-step round-trip through the shell.
359/// That refactor is worth doing; it is not worth folding into a bug-fix pass, so
360/// That refactor is worth doing; it is not worth folding into a bug-fix pass, so
360/// the impurity is documented rather than hidden.
361/// the impurity is documented rather than hidden.
362+// TODO: replace with crux_time effect for deterministic timestamps
361fn now_ms() -> i64 {
363fn now_ms() -> i64 {
362 SystemTime::now()
364 SystemTime::now()
363 .duration_since(UNIX_EPOCH)
365 .duration_since(UNIX_EPOCH)
@@ -729,10 +731,11 @@ impl App for Skrepka {
729 .filter(|c| !c.blocked)
731 .filter(|c| !c.blocked)
730 .map(|c| c.pubkey.clone())
732 .map(|c| c.pubkey.clone())
731 .collect();
733 .collect();
734+ let payload_json = Arc::new(protocol::serialize_payload(&payload, ts));
732 for peer in recipients {
735 for peer in recipients {
733 model.outbox.push_back(OutboxItem {
736 model.outbox.push_back(OutboxItem {
734 recipient: peer,
737 recipient: peer,
735− envelope_json: protocol::serialize_payload(&payload, ts),
738+ envelope_json: Arc::clone(&payload_json),
736 });
739 });
737 }
740 }
738 Command::all([
741 Command::all([
@@ -1160,7 +1163,7 @@ impl Skrepka {
1160 let text = Payload::Text { id, body };
1163 let text = Payload::Text { id, body };
1161 model.outbox.push_back(OutboxItem {
1164 model.outbox.push_back(OutboxItem {
1162 recipient: peer.clone(),
1165 recipient: peer.clone(),
1163− envelope_json: protocol::serialize_payload(&text, ts),
1166+ envelope_json: Arc::new(protocol::serialize_payload(&text, ts)),
1164 });
1167 });
1165 // On first contact, also share our profile.
1168 // On first contact, also share our profile.
1166 if first_to_peer {
1169 if first_to_peer {
@@ -1171,7 +1174,7 @@ impl Skrepka {
1171 };
1174 };
1172 model.outbox.push_back(OutboxItem {
1175 model.outbox.push_back(OutboxItem {
1173 recipient: peer.clone(),
1176 recipient: peer.clone(),
1174− envelope_json: protocol::serialize_payload(&profile, ts),
1177+ envelope_json: Arc::new(protocol::serialize_payload(&profile, ts)),
1175 });
1178 });
1176 }
1179 }
1177
1180
@@ -1270,10 +1273,12 @@ impl Skrepka {
1270 };
1273 };
1271
1274
1272 let now = now_ms();
1275 let now = now_ms();
1273− let mut touched_convos: Vec<String> = Vec::new();
1276+ let mut touched_convos: HashSet<String> = HashSet::new();
1274 let mut contacts_dirty = false;
1277 let mut contacts_dirty = false;
1275 // sender hex -> message ids needing acks
1278 // sender hex -> message ids needing acks
1276 let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
1279 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();
1277
1282
1278 let mut budget = MAX_POLL_TOTAL_BYTES;
1283 let mut budget = MAX_POLL_TOTAL_BYTES;
1279 for ev in page.events {
1284 for ev in page.events {
@@ -1338,8 +1343,12 @@ impl Skrepka {
1338 contacts_dirty = true;
1343 contacts_dirty = true;
1339 }
1344 }
1340 let convo = model.messages.entry(sender.clone()).or_default();
1345 let convo = model.messages.entry(sender.clone()).or_default();
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+ });
1341− // dedup by id
1350+ // dedup by id — O(1) via HashSet
1342− if !convo.iter().any(|m| m.id == msg_id) {
1351+ if !id_set.contains(&msg_id) {
1343 convo.push(StoredMessage {
1352 convo.push(StoredMessage {
1344 id: msg_id.clone(),
1353 id: msg_id.clone(),
1345 body,
1354 body,
@@ -1347,25 +1356,39 @@ impl Skrepka {
1347 outgoing: false,
1356 outgoing: false,
1348 delivered: false,
1357 delivered: false,
1349 });
1358 });
1359+ id_set.insert(msg_id.clone());
1350 convo.sort_by_key(|m| m.ts);
1360 convo.sort_by_key(|m| m.ts);
1351 trim_history(convo);
1361 trim_history(convo);
1352− if !touched_convos.contains(&sender) {
1362+ touched_convos.insert(sender.clone());
1353− touched_convos.push(sender.clone());
1354− }
1355 }
1363 }
1356 ack_targets.entry(sender).or_default().push(msg_id);
1364 ack_targets.entry(sender).or_default().push(msg_id);
1357 }
1365 }
1358 Payload::DeliveryAck { ack_ids } => {
1366 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();
1359 if let Some(convo) = model.messages.get_mut(&sender) {
1382 if let Some(convo) = model.messages.get_mut(&sender) {
1360 let mut changed = false;
1383 let mut changed = false;
1361 for m in convo.iter_mut() {
1384 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 {
1363 m.delivered = true;
1386 m.delivered = true;
1364 changed = true;
1387 changed = true;
1365 }
1388 }
1366 }
1389 }
1367− if changed && !touched_convos.contains(&sender) {
1390+ if changed {
1368− touched_convos.push(sender);
1391+ touched_convos.insert(sender);
1369 }
1392 }
1370 }
1393 }
1371 }
1394 }
@@ -1412,7 +1435,7 @@ impl Skrepka {
1412 };
1435 };
1413 model.outbox.push_back(OutboxItem {
1436 model.outbox.push_back(OutboxItem {
1414 recipient: sender.clone(),
1437 recipient: sender.clone(),
1415− envelope_json: protocol::serialize_payload(&payload, ack_ts),
1438+ envelope_json: Arc::new(protocol::serialize_payload(&payload, ack_ts)),
1416 });
1439 });
1417 }
1440 }
1418 }
1441 }
@@ -1653,7 +1676,7 @@ mod tests {
1653 let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
1676 let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
1654 m.outbox.push_back(OutboxItem {
1677 m.outbox.push_back(OutboxItem {
1655 recipient: bad,
1678 recipient: bad,
1656− envelope_json: "{}".into(),
1679+ envelope_json: Arc::new("{}".into()),
1657 });
1680 });
1658
1681
1659 let _ = app.update(Event::StartFlush, &mut m);
1682 let _ = app.update(Event::StartFlush, &mut m);
@@ -1834,12 +1857,12 @@ mod tests {
1834 for recipient in [&enemy, &friend] {
1857 for recipient in [&enemy, &friend] {
1835 m.outbox.push_back(OutboxItem {
1858 m.outbox.push_back(OutboxItem {
1836 recipient: recipient.clone(),
1859 recipient: recipient.clone(),
1837− envelope_json: protocol::serialize_payload(
1860+ envelope_json: Arc::new(protocol::serialize_payload(
1838 &Payload::DeliveryAck {
1861 &Payload::DeliveryAck {
1839 ack_ids: vec!["m1".into()],
1862 ack_ids: vec!["m1".into()],
1840 },
1863 },
1841 1,
1864 1,
1842− ),
1865+ )),
1843 });
1866 });
1844 }
1867 }
1845
1868
@@ -1932,7 +1955,7 @@ mod tests {
1932 m.flushing = true;
1955 m.flushing = true;
1933 m.outbox.push_back(OutboxItem {
1956 m.outbox.push_back(OutboxItem {
1934 recipient: peer_hex(9),
1957 recipient: peer_hex(9),
1935− envelope_json: "{}".into(),
1958+ envelope_json: Arc::new("{}".into()),
1936 });
1959 });
1937
1960
1938 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1961 let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
@@ -1950,7 +1973,7 @@ mod tests {
1950 for _ in 0..2 {
1973 for _ in 0..2 {
1951 m.outbox.push_back(OutboxItem {
1974 m.outbox.push_back(OutboxItem {
1952 recipient: peer_hex(9),
1975 recipient: peer_hex(9),
1953− envelope_json: "{}".into(),
1976+ envelope_json: Arc::new("{}".into()),
1954 });
1977 });
1955 }
1978 }
1956 // First send goes out and marks the model as flushing.
1979 // First send goes out and marks the model as flushing.
@@ -2532,4 +2555,57 @@ mod tests {
2532 assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
2555 assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
2533 assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
2556 assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
2534 }
2557 }
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+ }
2535}
2611}
core/src/crypto.rs+158−20
@@ -29,11 +29,40 @@ const HKDF_INFO: &[u8] = b"skrepka-v1";
29/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
29/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
30const AUTH_TAG: &str = "skrepka-auth-v1:";
30const AUTH_TAG: &str = "skrepka-auth-v1:";
31
31
32+/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
33+const WIRE_VERSION: u8 = 1;
34+
32pub const ED25519_SECRET_LEN: usize = 64;
35pub const ED25519_SECRET_LEN: usize = 64;
33pub const ED25519_PUBLIC_LEN: usize = 32;
36pub const ED25519_PUBLIC_LEN: usize = 32;
34pub const NONCE_LEN: usize = 24;
37pub const NONCE_LEN: usize = 24;
35−/// 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 16 (AEAD tag).
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.
36−pub const MIN_BLOB_LEN: usize = 32 + NONCE_LEN + 32 + 64 + 16;
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+}
37/// Upper bound on a blob we will even attempt to decrypt, in *decoded* bytes —
66/// Upper bound on a blob we will even attempt to decrypt, in *decoded* bytes —
38/// rejected on length before it costs us a scalar mult or an AEAD pass.
67/// rejected on length before it costs us a scalar mult or an AEAD pass.
39///
68///
@@ -274,17 +303,36 @@ pub fn encrypt(
274
303
275 let compressed = compress(plaintext);
304 let compressed = compress(plaintext);
276
305
277− // Sign recipient_pub || compressed (binds the blob to the recipient).
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).
278− let mut signed = Vec::with_capacity(32 + compressed.len());
322+ let mut signed = Vec::with_capacity(32 + 4 + compressed.len() + padding.len());
279 signed.extend_from_slice(recipient_ed_pub);
323 signed.extend_from_slice(recipient_ed_pub);
324+ signed.extend_from_slice(&compressed_len_bytes);
280 signed.extend_from_slice(&compressed);
325 signed.extend_from_slice(&compressed);
326+ signed.extend_from_slice(&padding);
281 let signature = sender.signing_key().sign(&signed);
327 let signature = sender.signing_key().sign(&signed);
282
328
283− // inner = sender_pub(32) || sig(64) || compressed
329+ // inner = sender_pub(32) || sig(64) || compressed_len(4) || compressed || padding
284− let mut inner = Vec::with_capacity(32 + 64 + compressed.len());
330+ let mut inner = Vec::with_capacity(32 + 64 + 4 + compressed.len() + padding.len());
285 inner.extend_from_slice(&sender_pub);
331 inner.extend_from_slice(&sender_pub);
286 inner.extend_from_slice(&signature.to_bytes());
332 inner.extend_from_slice(&signature.to_bytes());
333+ inner.extend_from_slice(&compressed_len_bytes);
287 inner.extend_from_slice(&compressed);
334 inner.extend_from_slice(&compressed);
335+ inner.extend_from_slice(&padding);
288
336
289 let mut nonce = [0u8; NONCE_LEN];
337 let mut nonce = [0u8; NONCE_LEN];
290 rng.fill_bytes(&mut nonce);
338 rng.fill_bytes(&mut nonce);
@@ -294,7 +342,8 @@ pub fn encrypt(
294 .map_err(|_| CryptoError::Encrypt)?;
342 .map_err(|_| CryptoError::Encrypt)?;
295 inner.zeroize();
343 inner.zeroize();
296
344
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);
298 blob.extend_from_slice(&eph_pub);
347 blob.extend_from_slice(&eph_pub);
299 blob.extend_from_slice(&nonce);
348 blob.extend_from_slice(&nonce);
300 blob.extend_from_slice(&ciphertext);
349 blob.extend_from_slice(&ciphertext);
@@ -316,11 +365,15 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
316 if blob.len() > MAX_BLOB_LEN {
365 if blob.len() > MAX_BLOB_LEN {
317 return Err(CryptoError::BlobTooLong);
366 return Err(CryptoError::BlobTooLong);
318 }
367 }
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+ }
319 let mut eph_pub = [0u8; 32];
372 let mut eph_pub = [0u8; 32];
320− eph_pub.copy_from_slice(&blob[..32]);
373+ eph_pub.copy_from_slice(&blob[1..33]);
321 let mut nonce = [0u8; NONCE_LEN];
374 let mut nonce = [0u8; NONCE_LEN];
322− nonce.copy_from_slice(&blob[32..32 + NONCE_LEN]);
375+ nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]);
323− let ciphertext = &blob[32 + NONCE_LEN..];
376+ let ciphertext = &blob[33 + NONCE_LEN..];
324
377
325 let recip_x_priv = ed25519_sk_to_x25519(&recipient.secret_key);
378 let recip_x_priv = ed25519_sk_to_x25519(&recipient.secret_key);
326 let raw_secret = x25519(&recip_x_priv, &eph_pub)?;
379 let raw_secret = x25519(&recip_x_priv, &eph_pub)?;
@@ -332,7 +385,7 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
332 .decrypt(XNonce::from_slice(&nonce), ciphertext)
385 .decrypt(XNonce::from_slice(&nonce), ciphertext)
333 .map_err(|_| CryptoError::Decrypt)?;
386 .map_err(|_| CryptoError::Decrypt)?;
334
387
335− if inner.len() < 32 + 64 {
388+ if inner.len() < 32 + 64 + 4 {
336 inner.zeroize();
389 inner.zeroize();
337 return Err(CryptoError::BlobTooShort);
390 return Err(CryptoError::BlobTooShort);
338 }
391 }
@@ -340,14 +393,26 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
340 sender_pub.copy_from_slice(&inner[..32]);
393 sender_pub.copy_from_slice(&inner[..32]);
341 let mut sig_bytes = [0u8; 64];
394 let mut sig_bytes = [0u8; 64];
342 sig_bytes.copy_from_slice(&inner[32..96]);
395 sig_bytes.copy_from_slice(&inner[32..96]);
343− let compressed = &inner[96..];
344
396
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+
345− // Verify signature over recipient_pub || compressed.
409+ // Verify signature over recipient_pub || inner[96..]
410+ // (covers compressed_len_bytes || compressed || padding).
346 let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
411 let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
347 let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
412 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));
349 signed.extend_from_slice(&recipient.public_key());
414 signed.extend_from_slice(&recipient.public_key());
350− signed.extend_from_slice(compressed);
415+ signed.extend_from_slice(&inner[96..]);
351 verifying
416 verifying
352 .verify(&signed, &signature)
417 .verify(&signed, &signature)
353 .map_err(|_| CryptoError::BadSignature)?;
418 .map_err(|_| CryptoError::BadSignature)?;
@@ -433,13 +498,15 @@ mod tests {
433 let alice = Identity::generate(&mut rng(1));
498 let alice = Identity::generate(&mut rng(1));
434 let bob = Identity::generate(&mut rng(2));
499 let bob = Identity::generate(&mut rng(2));
435 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
500 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
501+ // Version byte at offset 0.
502+ assert_eq!(blob[0], WIRE_VERSION);
436− // eph_pub occupies the first 32 bytes and equals x25519 basepoint * eph_priv;
503+ // eph_pub occupies bytes 1..33 and equals x25519 basepoint * eph_priv;
437 // we can't recompute eph_priv, but the nonce region must differ run-to-run.
504 // we can't recompute eph_priv, but the nonce region must differ run-to-run.
438 let blob2 = encrypt(&mut rng(8), &alice, &bob.public_key(), b"x").unwrap();
505 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");
440 assert_ne!(
507 assert_ne!(
441− &blob[32..32 + NONCE_LEN],
508+ &blob[33..33 + NONCE_LEN],
442− &blob2[32..32 + NONCE_LEN],
509+ &blob2[33..33 + NONCE_LEN],
443 "nonce must be random"
510 "nonce must be random"
444 );
511 );
445 }
512 }
@@ -506,13 +573,84 @@ mod tests {
506 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
573 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
507 // an all-zero shared secret for any scalar.
574 // an all-zero shared secret for any scalar.
508 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
575 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
576+ blob[0] = WIRE_VERSION;
509− blob[..32].fill(0);
577+ blob[1..33].fill(0);
510 assert_eq!(
578 assert_eq!(
511 decrypt(&bob, &blob).unwrap_err(),
579 decrypt(&bob, &blob).unwrap_err(),
512 CryptoError::InvalidEphemeralKey
580 CryptoError::InvalidEphemeralKey
513 );
581 );
514 }
582 }
515
583
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+
516 /// A recipient key that isn't a curve point can't be encrypted to, and the
654 /// A recipient key that isn't a curve point can't be encrypted to, and the
517 /// error says so rather than blaming decryption.
655 /// error says so rather than blaming decryption.
518 #[test]
656 #[test]
core/src/model.rs+6−1
@@ -1,6 +1,8 @@
1//! Domain types: persisted state (stored in the kv capability as JSON) and the
1//! Domain types: persisted state (stored in the kv capability as JSON) and the
2//! `ViewModel` types the Swift shell renders.
2//! `ViewModel` types the Swift shell renders.
3
3
4+use std::sync::Arc;
5+
4use facet::Facet;
6use facet::Facet;
5use serde::{Deserialize, Serialize};
7use serde::{Deserialize, Serialize};
6
8
@@ -58,6 +60,8 @@ pub struct Contact {
58 pub blocked: bool,
60 pub blocked: bool,
59 /// `ts` of the last applied profile message (drop older replays).
61 /// `ts` of the last applied profile message (drop older replays).
60 pub last_profile_ts: i64,
62 pub last_profile_ts: i64,
63+ /// `ts` of the last applied delivery.ack (drop older replays).
64+ pub last_ack_ts: i64,
61 pub added_at: i64,
65 pub added_at: i64,
62}
66}
63
67
@@ -71,6 +75,7 @@ impl Contact {
71 photo: None,
75 photo: None,
72 blocked: false,
76 blocked: false,
73 last_profile_ts: 0,
77 last_profile_ts: 0,
78+ last_ack_ts: 0,
74 added_at,
79 added_at,
75 }
80 }
76 }
81 }
@@ -104,7 +109,7 @@ pub struct StoredMessage {
104pub struct OutboxItem {
109pub struct OutboxItem {
105 pub recipient: String,
110 pub recipient: String,
106 /// The serialized plaintext payload JSON (encrypted only at send time).
111 /// The serialized plaintext payload JSON (encrypted only at send time).
107− pub envelope_json: String,
112+ pub envelope_json: Arc<String>,
108}
113}
109
114
110// ---------------------------------------------------------------------------
115// ---------------------------------------------------------------------------
server.knot+17−14
@@ -72,15 +72,15 @@ messageMaxAge = 2592000000
72-- Max length of a hex-encoded message body / opaque blob (40 MiB hex ≈ 20 MiB bytes).
72-- Max length of a hex-encoded message body / opaque blob (40 MiB hex ≈ 20 MiB bytes).
73maxBlobLen : Int
73maxBlobLen : Int
74maxBlobLen = 41943040
74maxBlobLen = 41943040
75−-- Min length of a hex-encoded blob: 336 chars = 168 bytes. A well-formed blob is
75+-- Min length of a hex-encoded blob: 346 chars = 173 bytes. A well-formed blob is
76−-- `ephemeral_x25519_pubkey (32) || nonce (24) || ciphertext`, and the ciphertext
76+-- `version (1) || ephemeral_x25519_pubkey (32) || nonce (24) || ciphertext`, and the
77−-- seals at minimum `sender_pubkey (32) || signature (64)` plus the 16-byte
77+-- ciphertext seals at minimum `sender_pubkey (32) || signature (64) || compressed_len (4)`
78−-- Poly1305 tag — so 32 + 24 + 32 + 64 + 16 = 168 bytes is the floor for a blob
78+-- plus the 16-byte Poly1305 tag — so 1 + 32 + 24 + 32 + 64 + 4 + 16 = 173 bytes is the
79−-- any client could possibly decrypt (PROTOCOL.md §3). Anything shorter is
79+-- floor for a blob any client could possibly decrypt (PROTOCOL.md §3). Anything shorter
80−-- garbage by construction: it cannot carry a sender identity, and storing it only
80+-- is garbage by construction: it cannot carry a sender identity, and storing it only
81-- burns mailbox rows and the recipient's poll bandwidth. Reject at the boundary.
81-- burns mailbox rows and the recipient's poll bandwidth. Reject at the boundary.
82minBlobLen : Int
82minBlobLen : Int
83−minBlobLen = 336
83+minBlobLen = 346
84-- Cap on items in a batched send request.
84-- Cap on items in a batched send request.
85maxBatchSize : Int
85maxBatchSize : Int
86maxBatchSize = 100
86maxBatchSize = 100
@@ -474,13 +474,16 @@ capPerServer = \items server maxN -> union (filter (\x -> not (x.server == serve
474-- desired length in characters (so n=48 yields 24 random bytes of entropy).
474-- desired length in characters (so n=48 yields 24 random bytes of entropy).
475-- Session bearers, /auth challenges, and federation auth nonces all use n=48
475-- Session bearers, /auth challenges, and federation auth nonces all use n=48
476-- (192 bits of entropy, well beyond birthday bound).
476-- (192 bits of entropy, well beyond birthday bound).
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.
477−genToken : Int -> IO {random} Text
481+genToken : Int -> Text -> IO {random} Text
478−genToken = \n -> if n <= 0
482+genToken = \n acc -> if n <= 0
479−then yield ""
483+then yield acc
480else do
484else do
481 d <- randomInt 16
485 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"))
484
487
485-- True when `s` is non-empty, even-length, and only lowercase hex digits.
488-- True when `s` is non-empty, even-length, and only lowercase hex digits.
486-- `chars` yields the *distinct* character set (relations dedupe), which is
489-- `chars` yields the *distinct* character set (relations dedupe), which is
@@ -1095,7 +1098,7 @@ forwardQueuedToServer = \pubkey serverName -> atomic (do
1095handleChallenge : PubkeyHex -> Text -> IO {rw *challenges, clock, random} (Result HttpError {challenge: Text, expiresAt: Timestamp})
1098handleChallenge : PubkeyHex -> Text -> IO {rw *challenges, clock, random} (Result HttpError {challenge: Text, expiresAt: Timestamp})
1096handleChallenge = \pubkey clientIp -> do
1099handleChallenge = \pubkey clientIp -> do
1097 t <- now
1100 t <- now
1098− challenge <- genToken 48
1101+ challenge <- genToken 48 ""
1099 let expiresAt = t + challengeExpiry
1102 let expiresAt = t + challengeExpiry
1100 atomic (do
1103 atomic (do
1101 ch <- *challenges
1104 ch <- *challenges
@@ -1111,7 +1114,7 @@ handleVerifyAuth : PubkeyHex -> Text -> Text -> Bool -> Text -> IO {rw *challeng
1111handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
1114handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
1112 t <- now
1115 t <- now
1113 let expiry = t + sessionExpiry
1116 let expiry = t + sessionExpiry
1114− token <- genToken 48
1117+ token <- genToken 48 ""
1115 -- The client signs (target host, challenge) under a domain-separation tag so a
1118 -- The client signs (target host, challenge) under a domain-separation tag so a
1116 -- relay server cannot replay the signature to a different server. We verify
1119 -- relay server cannot replay the signature to a different server. We verify
1117 -- against our own configured hostname (port stripped), which a relay cannot forge.
1120 -- against our own configured hostname (port stripped), which a relay cannot forge.