@@ -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]