@@ -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:";
32+/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
33+const WIRE_VERSION: u8 = 1;
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;
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];
48+/// Round `actual` up to the nearest padding boundary.
49+fn padded_len(actual: usize) -> usize {
51+ return PADDING_BUCKETS[0];
53+ for &bucket in &PADDING_BUCKETS {
54+ if actual <= bucket {
58+ // Above 65536: round to the next multiple of 65536.
59+ let remainder = actual % 65536;
63+ actual + 65536 - remainder
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.
@@ -274,17 +303,36 @@ pub fn encrypt(
275 let compressed = compress(plaintext);
304 let compressed = compress(plaintext);
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;
315+ let mut padding = vec![0u8; pad_len];
316+ rng.fill_bytes(&mut padding);
318+ let compressed_len_bytes = (compressed.len() as u32).to_be_bytes();
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);
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);
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();
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);
368+ // Version byte — reject any blob we don't know how to handle.
369+ if blob[0] != WIRE_VERSION {
370+ return Err(CryptoError::Decrypt);
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..];
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)?;
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);
@@ -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..];
397+ let compressed_len = u32::from_be_bytes([
403+ if 100 + compressed_len > inner.len() {
405+ return Err(CryptoError::Decrypt);
407+ let compressed = &inner[100..100 + compressed_len];
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"
@@ -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
584+ /// A short message must be padded so the blob size lands on a bucket boundary.
586+ fn blob_is_padded_to_bucket_size() {
587+ let alice = Identity::generate(&mut rng(1));
588+ let bob = Identity::generate(&mut rng(2));
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();
593+ PADDING_BUCKETS.contains(&blob_small.len()),
594+ "blob len {} is not a bucket boundary",
597+ assert_eq!(blob_small.len(), 256);
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();
603+ PADDING_BUCKETS.contains(&blob_large.len()) || blob_large.len() % 65536 == 0,
604+ "blob len {} is not a bucket boundary",
609+ /// The same plaintext encrypted with different RNG seeds must produce the
610+ /// same blob size (same bucket) but different blob contents (random padding).
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";
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();
620+ // Same bucket → same size.
621+ assert_eq!(blob1.len(), blob2.len(), "same plaintext must have same blob size");
623+ // Different RNG → different ephemeral key, nonce, and padding → different bytes.
624+ assert_ne!(&blob1[..], &blob2[..], "different RNG must produce different blobs");
626+ // Both must decrypt correctly.
627+ assert_eq!(decrypt(&bob, &blob1).unwrap().plaintext, msg);
628+ assert_eq!(decrypt(&bob, &blob2).unwrap().plaintext, msg);
631+ /// The compressed_len field must be parsed correctly so the decompressor
632+ /// never sees trailing padding bytes.
634+ fn compressed_len_field_is_parsed_correctly() {
635+ let alice = Identity::generate(&mut rng(1));
636+ let bob = Identity::generate(&mut rng(2));
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);
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());
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.