on master help sign in
skrepka/core/src/crypto.rs
894 lines 39 kB history raw
1//! Byte-exact port of the skrepka message crypto (see PROTOCOL.md §3).
2//!
3//! Per-message: ephemeral X25519, HKDF-SHA256, XChaCha20-Poly1305, Ed25519
4//! signature bound to the recipient, zstd-compressed plaintext.
5//!
6//! The Ed25519 secret key is the 64-byte libsodium form: `seed(32) || pub(32)`.
7//! The X25519 keys are derived from Ed25519 the same way libsodium's
8//! `crypto_sign_ed25519_{pk,sk}_to_curve25519` do, so blobs interoperate with
9//! any client following the spec.
10//!
11//! Everything an attacker controls is bounded here, because this is the first
12//! code a hostile relay or peer reaches: the blob is length-capped before any
13//! work is done on it, and the zstd frame inside is decompressed through a
14//! limited reader so a compression bomb cannot exhaust memory.
16use std::io::Read;
18use chacha20poly1305::aead::{Aead, Payload as AeadPayload};
19use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
20use curve25519_dalek::edwards::CompressedEdwardsY;
21use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
22use hkdf::Hkdf;
23use rand_core::{CryptoRng, RngCore};
24use sha2::{Digest, Sha256, Sha512};
25use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
27/// HKDF `info` string (PROTOCOL.md §3). Derived from the wire version so a
28/// future crypto revision that bumps the version byte also changes the KDF
29/// label automatically — the two must always move together (N2).
30const HKDF_INFO: &[u8] = b"skrepka-v2";
31/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
32const AUTH_TAG: &str = "skrepka-auth-v1:";
34/// Domain-separation tag for per-message signatures (PROTOCOL.md §3).
35/// Prevents cross-protocol signature reuse: an auth challenge signature
36/// begins with "skrepka-auth-v1:" while a message signature begins with
37/// this tag, so the same key cannot sign in one context and have it
38/// validate in another.
39const MSG_TAG: &[u8] = b"skrepka-msg-v2:";
41/// Wire-format version byte prepended to every encrypted blob (PROTOCOL.md §3).
42///
43/// Version 0x02 adds AEAD associated data (AAD): the cleartext header
44/// `version_byte || ephemeral_public || nonce` is authenticated alongside
45/// the ciphertext, so a relay that tampers with any envelope field —
46/// including swapping the `to` recipient — causes AEAD decryption to fail
47/// before the blob is ever acked (N1). The recipient's Ed25519 public key
48/// is also included in the AAD, binding the envelope `to` field to the
49/// inner signed recipient, so a `to`-swap is detected as an AEAD failure
50/// rather than a silent misdelivery.
51const WIRE_VERSION: u8 = 2;
53/// Build the AAD for AEAD: `version || ephemeral_pub || nonce || recipient_pub`.
54/// All envelope-level fields the relay can see are now cryptographically
55/// bound to the ciphertext (N1).
56fn aad(version: u8, eph_pub: &[u8; 32], nonce: &[u8; NONCE_LEN], recipient_pub: &[u8; 32]) -> Vec<u8> {
57 let mut aad = Vec::with_capacity(1 + 32 + NONCE_LEN + 32);
58 aad.push(version);
59 aad.extend_from_slice(eph_pub);
60 aad.extend_from_slice(nonce);
61 aad.extend_from_slice(recipient_pub);
62 aad
65pub const ED25519_SECRET_LEN: usize = 64;
66pub const ED25519_PUBLIC_LEN: usize = 32;
67pub const NONCE_LEN: usize = 24;
68/// `1+32+24+32+64+4+16` bytes = version, eph pubkey, nonce, sender pub, sig,
69/// compressed_len, AEAD tag. The 4-byte compressed_len field sits inside the AEAD
70/// ciphertext. The version byte, ephemeral pubkey, nonce, and recipient pubkey are
71/// AEAD AAD (v0x02).
72pub const MIN_BLOB_LEN: usize = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + 16;
74/// Blob sizes are padded up to these boundaries so the on-wire length does not
75/// reveal the exact plaintext (compressed) size to relays and network observers.
76/// Below 65536 we round up to the nearest bucket; above it we round to the next
77/// multiple of 65536 (MAX_BLOB_LEN is exactly 672 × 65536, so we never exceed it).
78const PADDING_BUCKETS: [usize; 9] = [256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536];
80/// Round `actual` up to the nearest padding boundary.
81fn padded_len(actual: usize) -> usize {
82 if actual == 0 {
83 return PADDING_BUCKETS[0];
84 }
85 for &bucket in &PADDING_BUCKETS {
86 if actual <= bucket {
87 return bucket;
88 }
89 }
90 // Above 65536: round to the next multiple of 65536.
91 let remainder = actual % 65536;
92 if remainder == 0 {
93 // D6: When actual is an exact multiple of 65536 above 65536,
94 // returning `actual` would give zero padding, revealing the exact
95 // compressed size. Always add at least one bucket of padding.
96 actual + 65536
97 } else {
98 actual + 65536 - remainder
99 }
101/// Upper bound on a blob we will even attempt to decrypt, in *decoded* bytes —
102/// rejected on length before it costs us a scalar mult or an AEAD pass.
103///
104/// Deliberately looser than the reference relay rather than equal to it: that
105/// relay's `maxBlobLen` (41943040) bounds the *hex* string, i.e. ~20 MiB decoded,
106/// so no blob it serves comes close to this. Sizing the client's ceiling to one
107/// relay's would make us reject blobs a differently-configured relay may legally
108/// deliver, and it buys no safety — the real bounds on attacker-controlled work
109/// are `MAX_POLL_TOTAL_BYTES` (the page) and `MAX_PLAINTEXT_LEN` (the
110/// decompression), both of which hold whatever this is set to.
111pub const MAX_BLOB_LEN: usize = 42 * 1024 * 1024;
112/// Upper bound on the *decompressed* plaintext inside a blob.
113///
114/// `zstd` frames advertise their content size, but a hostile peer can lie or
115/// simply craft a small frame that expands to gigabytes. Without a cap the
116/// decoder happily allocates all of it and iOS jetsams the app. Payloads are
117/// JSON (text bodies, a base64 avatar at worst), so 1 MiB is generous.
118pub const MAX_PLAINTEXT_LEN: usize = 1 << 20;
119/// Upper bound on a server-supplied auth challenge we are willing to sign.
120/// We sign whatever the relay hands us, so it must not be able to make us sign
121/// an arbitrarily large blob of its choosing.
122pub const MAX_CHALLENGE_LEN: usize = 256;
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum CryptoError {
126 BadKeyLength,
127 BlobTooShort,
128 BlobTooLong,
129 /// A 32-byte key that is not a valid curve point.
130 InvalidPublicKey,
131 /// The X25519 exchange produced an all-zero shared secret (low-order point).
132 InvalidEphemeralKey,
133 Encrypt,
134 Decrypt,
135 BadSignature,
136 Decompress,
137 ChallengeTooLong,
140impl std::fmt::Display for CryptoError {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 let s = match self {
143 CryptoError::BadKeyLength => "bad key length",
144 CryptoError::BlobTooShort => "blob too short",
145 CryptoError::BlobTooLong => "blob too long",
146 CryptoError::InvalidPublicKey => "invalid public key",
147 CryptoError::InvalidEphemeralKey => "invalid ephemeral key",
148 CryptoError::Encrypt => "encryption failed",
149 CryptoError::Decrypt => "decryption failed",
150 CryptoError::BadSignature => "signature verification failed",
151 CryptoError::Decompress => "decompression failed",
152 CryptoError::ChallengeTooLong => "auth challenge too long",
153 };
154 f.write_str(s)
155 }
158impl std::error::Error for CryptoError {}
160/// A generated identity: the 64-byte libsodium-form secret key. The public key
161/// is the last 32 bytes.
162///
163/// `ZeroizeOnDrop` wipes the seed when the value goes away. `Identity` is cloned
164/// freely (once per encrypt/decrypt), so each of those copies is wiped too.
165///
166/// The derived X25519 keys (`x25519_priv`, `x25519_pub`) are computed once at
167/// construction and cached: `decrypt` is called per-blob in a poll page, and
168/// recomputing the SHA-512 + Edwards decompress on every call is a measurable
169/// CPU multiplier under a flood of small blobs. Both are `Zeroizing` so the
170/// private half is wiped on drop alongside the seed.
171#[derive(Clone, ZeroizeOnDrop)]
172pub struct Identity {
173 pub secret_key: [u8; ED25519_SECRET_LEN],
174 x25519_priv: [u8; 32],
175 x25519_pub: [u8; 32],
178impl Identity {
179 /// Build from a 64-byte secret key (e.g. loaded from the Keychain).
180 ///
181 /// The trailing 32 bytes are *claimed* to be the public key; we re-derive it
182 /// from the seed and check. A mismatched pair would otherwise sign under one
183 /// key while advertising another — every message we send would be silently
184 /// unverifiable at the recipient.
185 pub fn from_secret_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
186 if bytes.len() != ED25519_SECRET_LEN {
187 return Err(CryptoError::BadKeyLength);
188 }
189 let mut secret_key = [0u8; ED25519_SECRET_LEN];
190 secret_key.copy_from_slice(bytes);
192 let mut seed = Zeroizing::new([0u8; 32]);
193 seed.copy_from_slice(&secret_key[..32]);
194 let derived = SigningKey::from_bytes(&seed);
195 if derived.verifying_key().as_bytes() != &secret_key[32..] {
196 secret_key.zeroize();
197 return Err(CryptoError::BadKeyLength);
198 }
200 Identity::with_x25519(secret_key)
201 }
203 /// Generate a fresh identity from a CSPRNG.
204 pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Result<Self, CryptoError> {
205 let mut seed = Zeroizing::new([0u8; 32]);
206 rng.fill_bytes(&mut *seed);
207 Self::from_seed(&seed)
208 }
210 /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
211 pub fn from_seed(seed: &[u8; 32]) -> Result<Self, CryptoError> {
212 let signing = SigningKey::from_bytes(seed);
213 let public = signing.verifying_key();
214 let mut secret_key = [0u8; ED25519_SECRET_LEN];
215 secret_key[..32].copy_from_slice(seed);
216 secret_key[32..].copy_from_slice(public.as_bytes());
217 Identity::with_x25519(secret_key)
218 }
220 pub fn public_key(&self) -> [u8; ED25519_PUBLIC_LEN] {
221 let mut pk = [0u8; ED25519_PUBLIC_LEN];
222 pk.copy_from_slice(&self.secret_key[32..]);
223 pk
224 }
226 pub fn public_key_hex(&self) -> String {
227 hex::encode(self.public_key())
228 }
230 fn signing_key(&self) -> SigningKey {
231 let mut seed = Zeroizing::new([0u8; 32]);
232 seed.copy_from_slice(&self.secret_key[..32]);
233 SigningKey::from_bytes(&seed)
234 }
236 /// Compute the cached X25519 keys from the Ed25519 secret key.
237 ///
238 /// Q49: Also checks that the derived X25519 public key is not an all-zero
239 /// (low-order) point. The probability of generating such a key is negligible,
240 /// but if it ever happened every encryption to this identity would fail with
241 /// `InvalidEphemeralKey` and the user would be unable to receive messages
242 /// with no indication of why. Catching it at construction time gives a
243 /// clear error instead.
244 fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Result<Self, CryptoError> {
245 let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
246 let x25519_pub = x25519_dalek::x25519(x25519_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
247 // D33: Return an error instead of just printing a warning when the
248 // derived X25519 public key is all-zero (low-order point). The
249 // probability of generating such a key is negligible, but if it ever
250 // happened every encryption to this identity would fail with
251 // `InvalidEphemeralKey` and the user would be unable to receive
252 // messages with no indication of why. Catching it at construction time
253 // gives a clear error instead.
254 if x25519_pub.iter().all(|b| *b == 0) {
255 return Err(CryptoError::InvalidPublicKey);
256 }
257 Ok(Identity {
258 secret_key,
259 x25519_priv,
260 x25519_pub,
261 })
262 }
264 /// Cached X25519 private key derived from the Ed25519 seed (libsodium
265 /// `crypto_sign_ed25519_sk_to_curve25519`). Avoids a per-decrypt SHA-512.
266 fn x25519_private(&self) -> &[u8; 32] {
267 &self.x25519_priv
268 }
270 /// Cached X25519 public key derived from the Ed25519 public key (libsodium
271 /// `crypto_sign_ed25519_pk_to_curve25519`). Avoids a per-decrypt decompress.
272 fn x25519_public(&self) -> &[u8; 32] {
273 &self.x25519_pub
274 }
276 /// Sign the auth challenge: `ed25519(sk, "skrepka-auth-v1:" + host + ":" + challenge)`.
277 /// `server_host` must already be the bare lowercased hostname.
278 ///
279 /// The challenge comes straight off the wire from a relay we have not
280 /// authenticated, so its length is bounded before it goes into the hash.
281 pub fn sign_challenge(&self, server_host: &str, challenge: &str) -> Result<String, CryptoError> {
282 if challenge.len() > MAX_CHALLENGE_LEN {
283 return Err(CryptoError::ChallengeTooLong);
284 }
285 let message = format!("{AUTH_TAG}{server_host}:{challenge}");
286 let sig = self.signing_key().sign(message.as_bytes());
287 Ok(hex::encode(sig.to_bytes()))
288 }
291/// libsodium `crypto_sign_ed25519_pk_to_curve25519`: Edwards Y -> Montgomery u.
292fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
293 let compressed = CompressedEdwardsY(*ed_pub);
294 let point = compressed
295 .decompress()
296 .ok_or(CryptoError::InvalidPublicKey)?;
297 let montgomery = point.to_montgomery().to_bytes();
298 // D3: A 32-byte all-zero input is the Ed25519 identity point, which
299 // produces an all-zero Montgomery u-coordinate — a low-order point.
300 // Reject it explicitly, separate from the x25519 shared-secret check.
301 if montgomery.iter().all(|&b| b == 0) {
302 return Err(CryptoError::InvalidPublicKey);
303 }
304 Ok(montgomery)
307/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
308fn ed25519_sk_to_x25519(secret_key: &[u8; 64]) -> Zeroizing<[u8; 32]> {
309 let mut h = Sha512::new();
310 h.update(&secret_key[..32]);
311 let mut digest = h.finalize();
312 let mut scalar = Zeroizing::new([0u8; 32]);
313 scalar.copy_from_slice(&digest[..32]);
314 digest.as_mut_slice().zeroize();
315 // X25519 clamp.
316 scalar[0] &= 248;
317 scalar[31] &= 127;
318 scalar[31] |= 64;
319 scalar
322/// Raw X25519 scalar multiplication (no hashing), as libsodium `crypto_scalarmult`.
323///
324/// An all-zero output means the peer supplied a low-order point: every scalar
325/// then yields the same shared secret, so the "encryption" would be forgeable by
326/// anyone. libsodium's `crypto_scalarmult` rejects this and so do we.
327fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, CryptoError> {
328 let shared = Zeroizing::new(x25519_dalek::x25519(*scalar, *point));
329 if shared.iter().all(|b| *b == 0) {
330 return Err(CryptoError::InvalidEphemeralKey);
331 }
332 Ok(shared)
335/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v2", 32).
336fn derive_key(
337 raw_secret: &[u8; 32],
338 eph_pub: &[u8; 32],
339 recip_x_pub: &[u8; 32],
340) -> Zeroizing<[u8; 32]> {
341 let mut salt = [0u8; 64];
342 salt[..32].copy_from_slice(eph_pub);
343 salt[32..].copy_from_slice(recip_x_pub);
344 let hk = Hkdf::<Sha256>::new(Some(&salt), raw_secret);
345 let mut okm = Zeroizing::new([0u8; 32]);
346 hk.expand(HKDF_INFO, &mut *okm)
347 .expect("32 is a valid length");
348 okm
351fn compress(plaintext: &[u8]) -> Vec<u8> {
352 // zstd frame with embedded content size; decodable without a capacity hint.
353 zstd::encode_all(plaintext, 3).expect("zstd compression is infallible for in-memory input")
356/// Decompress a zstd frame, refusing to produce more than `MAX_PLAINTEXT_LEN`.
357///
358/// `zstd::decode_all` grows its output buffer to whatever the frame expands to,
359/// which is attacker-chosen. Streaming through a `take`-limited reader bounds
360/// the allocation instead: we read one byte past the cap so that a frame which
361/// *would* overrun is detected rather than silently truncated.
362fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
363 let mut decoder = zstd::stream::Decoder::new(data).map_err(|_| CryptoError::Decompress)?;
364 // Cap the decoder's window buffer so a crafted frame declaring a huge
365 // windowLog (up to 27 = 128 MiB by default) cannot force a matching
366 // allocation before the output-size cap even applies. 2^21 = 2 MiB is
367 // comfortably above `MAX_PLAINTEXT_LEN` (1 MiB), so every legitimate frame
368 // still decodes; frames declaring a larger window are rejected.
369 decoder
370 .window_log_max(21)
371 .map_err(|_| CryptoError::Decompress)?;
372 let mut out = Vec::with_capacity(4096);
373 (&mut decoder)
374 .take(MAX_PLAINTEXT_LEN as u64 + 1)
375 .read_to_end(&mut out)
376 .map_err(|_| CryptoError::Decompress)?;
377 if out.len() > MAX_PLAINTEXT_LEN {
378 return Err(CryptoError::Decompress);
379 }
380 Ok(out)
383/// Encrypt `plaintext` for `recipient_ed_pub` (32-byte Ed25519 pubkey).
384/// Returns the on-wire blob: `eph_pub(32) || nonce(24) || ciphertext`.
385pub fn encrypt(
386 rng: &mut (impl RngCore + CryptoRng),
387 sender: &Identity,
388 recipient_ed_pub: &[u8; 32],
389 plaintext: &[u8],
390) -> Result<Vec<u8>, CryptoError> {
391 // D28: Reject a plaintext over the cap before compressing and encrypting
392 // — the recipient will reject it on decompression anyway, so the work
393 // is wasted. CryptoError::Encrypt signals an encryption-side failure.
394 if plaintext.len() > MAX_PLAINTEXT_LEN {
395 return Err(CryptoError::Encrypt);
396 }
397 let sender_pub = sender.public_key();
399 // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
400 let mut eph_priv = Zeroizing::new([0u8; 32]);
401 rng.fill_bytes(&mut *eph_priv);
402 let eph_pub = x25519_dalek::x25519(*eph_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
404 let recip_x = ed25519_pk_to_x25519(recipient_ed_pub)?;
405 let raw_secret = x25519(&eph_priv, &recip_x)?;
406 let key = derive_key(&raw_secret, &eph_pub, &recip_x);
408 let compressed = Zeroizing::new(compress(plaintext));
410 // Compute padding: round the full on-wire blob size up to a bucket boundary
411 // so relays and network observers cannot tell the exact (compressed) length.
412 // unpadded = 1 (version) + 32 (eph_pub) + 24 (nonce) + inner + 16 (AEAD tag)
413 // = 1 + 32 + 24 + (32 + 64 + 4 + compressed.len()) + 16
414 // = 173 + compressed.len()
415 let unpadded_blob_len = 1 + 32 + NONCE_LEN + 32 + 64 + 4 + compressed.len() + 16;
416 let target = padded_len(unpadded_blob_len);
417 let pad_len = target - unpadded_blob_len;
419 let mut padding = Zeroizing::new(vec![0u8; pad_len]);
420 rng.fill_bytes(&mut padding);
422 let compressed_len_bytes = (compressed.len() as u32).to_be_bytes();
424 // Sign recipient_pub || compressed_len_bytes || compressed || padding
425 // (i.e. recipient_pub || everything after the 96-byte header in inner).
426 let mut signed = Zeroizing::new(Vec::with_capacity(MSG_TAG.len() + 32 + 4 + compressed.len() + padding.len()));
427 signed.extend_from_slice(MSG_TAG);
428 signed.extend_from_slice(recipient_ed_pub);
429 signed.extend_from_slice(&compressed_len_bytes);
430 signed.extend_from_slice(&compressed);
431 signed.extend_from_slice(&padding);
432 let signature = sender.signing_key().sign(&signed);
434 // inner = sender_pub(32) || sig(64) || compressed_len(4) || compressed || padding
435 //
436 // `Zeroizing` so the sender's pubkey, signature, and the compressed plaintext
437 // are wiped from memory even on the AEAD failure path — the `?` below returns
438 // early, and a plain `Vec` would be dropped without zeroization. Every other
439 // sensitive intermediate here (`compressed`, `signed`, `padding`) is already
440 // `Zeroizing`; `inner` holds copies of all of them plus the sender identity.
441 let mut inner = Zeroizing::new(Vec::with_capacity(32 + 64 + 4 + compressed.len() + padding.len()));
442 inner.extend_from_slice(&sender_pub);
443 inner.extend_from_slice(&signature.to_bytes());
444 inner.extend_from_slice(&compressed_len_bytes);
445 inner.extend_from_slice(&compressed);
446 inner.extend_from_slice(&padding);
448 let mut nonce = [0u8; NONCE_LEN];
449 rng.fill_bytes(&mut nonce);
450 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
451 // AAD binds the envelope header (version, ephemeral pubkey, nonce) and
452 // the recipient identity to the ciphertext (N1). A relay that swaps the
453 // `to` field causes the wrong recipient to fail AEAD decryption, and the
454 // original recipient's message stays in the mailbox instead of being
455 // silently misdelivered.
456 let aad_buf = aad(WIRE_VERSION, &eph_pub, &nonce, recipient_ed_pub);
457 let payload = AeadPayload {
458 msg: inner.as_ref(),
459 aad: &aad_buf,
460 };
461 let ciphertext = cipher
462 .encrypt(XNonce::from_slice(&nonce), payload)
463 .map_err(|_| CryptoError::Encrypt)?;
464 inner.zeroize();
466 let mut blob = Vec::with_capacity(1 + 32 + NONCE_LEN + ciphertext.len());
467 blob.push(WIRE_VERSION);
468 blob.extend_from_slice(&eph_pub);
469 blob.extend_from_slice(&nonce);
470 blob.extend_from_slice(&ciphertext);
471 Ok(blob)
474/// Result of decrypting a blob: the plaintext and the sender's Ed25519 pubkey (hex).
475///
476/// `plaintext` is `Zeroizing<Vec<u8>>` so the decrypted message content is wiped
477/// from memory when the value is dropped, not left in freed memory for a heap
478/// inspection or a jetsam snapshot. `Deref` makes `&dec.plaintext` work as `&[u8]`
479/// via auto-deref, so callers that only read the bytes need no change.
480#[derive(Debug, Clone)]
481pub struct Decrypted {
482 pub plaintext: Zeroizing<Vec<u8>>,
483 pub sender_hex: String,
486/// Decrypt a blob addressed to `recipient`. Verifies the sender's signature.
487pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoError> {
488 if blob.len() < MIN_BLOB_LEN {
489 return Err(CryptoError::BlobTooShort);
490 }
491 if blob.len() > MAX_BLOB_LEN {
492 return Err(CryptoError::BlobTooLong);
493 }
494 // Version byte — reject any blob we don't know how to handle.
495 if blob[0] != WIRE_VERSION {
496 return Err(CryptoError::Decrypt);
497 }
498 let mut eph_pub = [0u8; 32];
499 eph_pub.copy_from_slice(&blob[1..33]);
500 let mut nonce = [0u8; NONCE_LEN];
501 nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]);
502 let ciphertext = &blob[33 + NONCE_LEN..];
504 let recip_x_priv = recipient.x25519_private();
505 let raw_secret = x25519(recip_x_priv, &eph_pub)?;
506 let recip_x_pub = recipient.x25519_public();
507 let key = derive_key(&raw_secret, &eph_pub, recip_x_pub);
509 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
510 // Reconstruct the same AAD used during encryption: the envelope header
511 // and the recipient identity. This binds the envelope `to` field to the
512 // ciphertext — a relay that swapped `to` causes AEAD failure here, not
513 // a silent misdelivery (N1).
514 let recip_pub = recipient.public_key();
515 let aad_buf = aad(blob[0], &eph_pub, &nonce, &recip_pub);
516 let payload = AeadPayload { msg: ciphertext, aad: &aad_buf };
517 let mut inner = Zeroizing::new(
518 cipher
519 .decrypt(XNonce::from_slice(&nonce), payload)
520 .map_err(|_| CryptoError::Decrypt)?,
521 );
523 if inner.len() < 32 + 64 + 4 {
524 inner.zeroize();
525 return Err(CryptoError::BlobTooShort);
526 }
527 let mut sender_pub = [0u8; 32];
528 sender_pub.copy_from_slice(&inner[..32]);
529 let mut sig_bytes = [0u8; 64];
530 sig_bytes.copy_from_slice(&inner[32..96]);
532 let compressed_len = u32::from_be_bytes([
533 inner[96],
534 inner[97],
535 inner[98],
536 inner[99],
537 ]) as usize;
538 if compressed_len > inner.len().saturating_sub(100) {
539 inner.zeroize();
540 return Err(CryptoError::Decrypt);
541 }
542 let compressed = &inner[100..100 + compressed_len];
544 // Verify signature over recipient_pub || inner[96..]
545 // (covers compressed_len_bytes || compressed || padding).
546 let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
547 let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
548 let mut signed = Zeroizing::new(Vec::with_capacity(MSG_TAG.len() + 32 + (inner.len() - 96)));
549 signed.extend_from_slice(MSG_TAG);
550 signed.extend_from_slice(&recipient.public_key());
551 signed.extend_from_slice(&inner[96..]);
552 verifying
553 .verify_strict(&signed, &signature)
554 .map_err(|_| CryptoError::BadSignature)?;
556 let plaintext = decompress(compressed)?;
557 // `inner` and `signed` hold the decrypted sender identity and message
558 // content; zeroize them explicitly on the success path too, not just on
559 // errors (the `Zeroizing` wrapper handles drop, but being explicit here
560 // keeps the success and error paths symmetric).
561 inner.zeroize();
562 signed.zeroize();
563 Ok(Decrypted {
564 plaintext: Zeroizing::new(plaintext),
565 sender_hex: hex::encode(sender_pub),
566 })
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use rand_chacha::rand_core::SeedableRng;
573 use rand_chacha::ChaCha20Rng;
575 fn rng(seed: u64) -> ChaCha20Rng {
576 ChaCha20Rng::seed_from_u64(seed)
577 }
579 #[test]
580 fn identity_pub_is_last_32_bytes_and_consistent() {
581 let id = Identity::generate(&mut rng(1)).unwrap();
582 assert_eq!(&id.secret_key[32..], &id.public_key());
583 // Re-deriving from the seed yields the same public key.
584 let mut seed = [0u8; 32];
585 seed.copy_from_slice(&id.secret_key[..32]);
586 let id2 = Identity::from_seed(&seed).unwrap();
587 assert_eq!(id.public_key(), id2.public_key());
588 }
590 /// A 64-byte key whose trailing pubkey doesn't match its seed would sign
591 /// under one identity while claiming another.
592 #[test]
593 fn from_secret_bytes_rejects_a_seed_pubkey_mismatch() {
594 let id = Identity::from_seed(&[3u8; 32]).unwrap();
595 assert!(Identity::from_secret_bytes(&id.secret_key).is_ok());
597 let mut tampered = id.secret_key;
598 tampered[32] ^= 0x01; // claim a different public key
599 assert!(matches!(
600 Identity::from_secret_bytes(&tampered),
601 Err(CryptoError::BadKeyLength)
602 ));
603 }
605 #[test]
606 fn round_trip_recovers_plaintext_and_sender() {
607 let alice = Identity::generate(&mut rng(10)).unwrap();
608 let bob = Identity::generate(&mut rng(20)).unwrap();
609 let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
611 let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
612 assert!(blob.len() >= MIN_BLOB_LEN);
614 let out = decrypt(&bob, &blob).unwrap();
615 assert_eq!(out.plaintext.as_slice(), msg);
616 assert_eq!(out.sender_hex, alice.public_key_hex());
617 }
619 #[test]
620 fn wrong_recipient_cannot_decrypt() {
621 let alice = Identity::generate(&mut rng(1)).unwrap();
622 let bob = Identity::generate(&mut rng(2)).unwrap();
623 let eve = Identity::generate(&mut rng(3)).unwrap();
624 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
625 assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
626 }
628 #[test]
629 fn tampered_ciphertext_is_rejected() {
630 let alice = Identity::generate(&mut rng(1)).unwrap();
631 let bob = Identity::generate(&mut rng(2)).unwrap();
632 let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
633 let last = blob.len() - 1;
634 blob[last] ^= 0xff;
635 assert!(decrypt(&bob, &blob).is_err());
636 }
638 #[test]
639 fn blob_layout_offsets() {
640 let alice = Identity::generate(&mut rng(1)).unwrap();
641 let bob = Identity::generate(&mut rng(2)).unwrap();
642 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
643 // Version byte at offset 0.
644 assert_eq!(blob[0], WIRE_VERSION);
645 // eph_pub occupies bytes 1..33 and equals x25519 basepoint * eph_priv;
646 // we can't recompute eph_priv, but the nonce region must differ run-to-run.
647 let blob2 = encrypt(&mut rng(8), &alice, &bob.public_key(), b"x").unwrap();
648 assert_ne!(&blob[1..33], &blob2[1..33], "ephemeral pubkey must be random");
649 assert_ne!(
650 &blob[33..33 + NONCE_LEN],
651 &blob2[33..33 + NONCE_LEN],
652 "nonce must be random"
653 );
654 }
656 #[test]
657 fn too_short_blob_errors() {
658 let bob = Identity::generate(&mut rng(2)).unwrap();
659 assert_eq!(
660 decrypt(&bob, &[0u8; 10]).unwrap_err(),
661 CryptoError::BlobTooShort
662 );
663 }
665 /// An oversized blob is rejected on length alone — before it costs us a
666 /// scalar mult, an AEAD pass, or a decompression.
667 #[test]
668 fn too_long_blob_errors() {
669 let bob = Identity::generate(&mut rng(2)).unwrap();
670 let huge = vec![0u8; MAX_BLOB_LEN + 1];
671 assert_eq!(decrypt(&bob, &huge).unwrap_err(), CryptoError::BlobTooLong);
672 }
674 /// The decompression bomb: a small zstd frame that expands past the cap must
675 /// error out rather than allocate its way to a jetsam kill.
676 #[test]
677 fn a_decompression_bomb_is_refused() {
678 let bomb = compress(&vec![0u8; MAX_PLAINTEXT_LEN + 1]);
679 assert!(
680 bomb.len() < 4096,
681 "the frame really is small ({} bytes)",
682 bomb.len()
683 );
684 assert_eq!(decompress(&bomb).unwrap_err(), CryptoError::Decompress);
686 // ...and the same bomb sent through encrypt is rejected before
687 // compression/encryption work is done (D28).
688 let alice = Identity::generate(&mut rng(1)).unwrap();
689 let bob = Identity::generate(&mut rng(2)).unwrap();
690 assert_eq!(
691 encrypt(
692 &mut rng(3),
693 &alice,
694 &bob.public_key(),
695 &vec![0u8; MAX_PLAINTEXT_LEN + 1],
696 )
697 .unwrap_err(),
698 CryptoError::Encrypt,
699 );
700 }
702 /// A plaintext right at the cap still round-trips: the bound is inclusive.
703 #[test]
704 fn a_plaintext_at_the_cap_still_round_trips() {
705 let alice = Identity::generate(&mut rng(1)).unwrap();
706 let bob = Identity::generate(&mut rng(2)).unwrap();
707 let msg = vec![7u8; MAX_PLAINTEXT_LEN];
708 let blob = encrypt(&mut rng(3), &alice, &bob.public_key(), &msg).unwrap();
709 assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg.as_slice());
710 }
712 /// A low-order ephemeral point makes every recipient derive the same shared
713 /// secret, so the sender's "encryption" is forgeable. Reject it.
714 #[test]
715 fn a_low_order_ephemeral_point_is_rejected() {
716 let bob = Identity::generate(&mut rng(2)).unwrap();
717 // eph_pub = 0 is the canonical low-order point; x25519 with it yields
718 // an all-zero shared secret for any scalar.
719 let mut blob = vec![0u8; MIN_BLOB_LEN + 1];
720 blob[0] = WIRE_VERSION;
721 blob[1..33].fill(0);
722 assert_eq!(
723 decrypt(&bob, &blob).unwrap_err(),
724 CryptoError::InvalidEphemeralKey
725 );
726 }
728 /// A short message must be padded so the blob size lands on a bucket boundary.
729 #[test]
730 fn blob_is_padded_to_bucket_size() {
731 let alice = Identity::generate(&mut rng(1)).unwrap();
732 let bob = Identity::generate(&mut rng(2)).unwrap();
734 // Short message → should round up to the smallest bucket (256).
735 let blob_small = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
736 assert!(
737 PADDING_BUCKETS.contains(&blob_small.len()),
738 "blob len {} is not a bucket boundary",
739 blob_small.len()
740 );
741 assert_eq!(blob_small.len(), 256);
743 // A larger message should also land on a bucket boundary.
744 let large_msg = vec![0u8; 300];
745 let blob_large = encrypt(&mut rng(8), &alice, &bob.public_key(), &large_msg).unwrap();
746 assert!(
747 PADDING_BUCKETS.contains(&blob_large.len()) || blob_large.len().is_multiple_of(65536),
748 "blob len {} is not a bucket boundary",
749 blob_large.len()
750 );
751 }
753 /// The same plaintext encrypted with different RNG seeds must produce the
754 /// same blob size (same bucket) but different blob contents (random padding).
755 #[test]
756 fn padding_is_random_per_message() {
757 let alice = Identity::generate(&mut rng(1)).unwrap();
758 let bob = Identity::generate(&mut rng(2)).unwrap();
759 let msg = b"hello bob, this is a test message";
761 let blob1 = encrypt(&mut rng(100), &alice, &bob.public_key(), msg).unwrap();
762 let blob2 = encrypt(&mut rng(200), &alice, &bob.public_key(), msg).unwrap();
764 // Same bucket → same size.
765 assert_eq!(blob1.len(), blob2.len(), "same plaintext must have same blob size");
767 // Different RNG → different ephemeral key, nonce, and padding → different bytes.
768 assert_ne!(&blob1[..], &blob2[..], "different RNG must produce different blobs");
770 // Both must decrypt correctly.
771 assert_eq!(decrypt(&bob, &blob1).unwrap().plaintext.as_slice(), msg);
772 assert_eq!(decrypt(&bob, &blob2).unwrap().plaintext.as_slice(), msg);
773 }
775 /// The compressed_len field must be parsed correctly so the decompressor
776 /// never sees trailing padding bytes.
777 #[test]
778 fn compressed_len_field_is_parsed_correctly() {
779 let alice = Identity::generate(&mut rng(1)).unwrap();
780 let bob = Identity::generate(&mut rng(2)).unwrap();
782 // Normal round-trip.
783 let msg = b"round trip test with some content";
784 let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), msg).unwrap();
785 assert_eq!(decrypt(&bob, &blob).unwrap().plaintext.as_slice(), msg);
787 // A message whose compressed size happens to produce a blob that lands
788 // exactly on a bucket boundary (zero padding). We can't easily engineer
789 // this, but we can verify the decrypt path works with the actual data.
790 // Just verify that every bucket-sized blob round-trips.
791 for size in [1usize, 10, 100, 200, 500, 1000, 5000] {
792 let m = vec![0xABu8; size];
793 let b = encrypt(&mut rng(42), &alice, &bob.public_key(), &m).unwrap();
794 assert_eq!(decrypt(&bob, &b).unwrap().plaintext.as_slice(), m.as_slice());
795 }
796 }
798 /// A recipient key that isn't a curve point can't be encrypted to, and the
799 /// error says so rather than blaming decryption.
800 #[test]
801 fn encrypting_to_an_off_curve_key_reports_an_invalid_public_key() {
802 let alice = Identity::generate(&mut rng(1)).unwrap();
803 let not_a_point = [0xabu8; 32];
804 assert_eq!(
805 encrypt(&mut rng(2), &alice, &not_a_point, b"x").unwrap_err(),
806 CryptoError::InvalidPublicKey
807 );
808 }
810 #[test]
811 fn sk_to_x25519_clamps() {
812 let id = Identity::generate(&mut rng(5)).unwrap();
813 let scalar = ed25519_sk_to_x25519(&id.secret_key);
814 assert_eq!(scalar[0] & 7, 0);
815 assert_eq!(scalar[31] & 64, 64);
816 assert_eq!(scalar[31] & 128, 0);
817 }
819 #[test]
820 fn challenge_signature_is_deterministic_and_host_bound() {
821 let id = Identity::from_seed(&[7u8; 32]).unwrap();
822 let a = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
823 let b = id.sign_challenge("relay.example.com", "deadbeef").unwrap();
824 let c = id.sign_challenge("other.example.com", "deadbeef").unwrap();
825 assert_eq!(a, b, "Ed25519 is deterministic");
826 assert_ne!(a, c, "signature is bound to the host");
827 assert_eq!(a.len(), 128, "64-byte sig in hex");
828 }
830 /// We sign whatever a not-yet-trusted relay hands us, so its size is bounded.
831 #[test]
832 fn an_oversized_challenge_is_not_signed() {
833 let id = Identity::from_seed(&[7u8; 32]).unwrap();
834 let huge = "a".repeat(MAX_CHALLENGE_LEN + 1);
835 assert_eq!(
836 id.sign_challenge("relay.example.com", &huge).unwrap_err(),
837 CryptoError::ChallengeTooLong
838 );
839 // The bound itself is inclusive.
840 let at_cap = "a".repeat(MAX_CHALLENGE_LEN);
841 assert!(id.sign_challenge("relay.example.com", &at_cap).is_ok());
842 }
844 /// A blob encrypted to Bob cannot be decrypted by Charlie — the key
845 /// derivation itself binds the recipient (the AEAD key is a function
846 /// of the recipient's private key and the ephemeral public key). The
847 /// AAD additionally authenticates the recipient pubkey, but the tag
848 /// check would fail even without it, because Charlie derives a
849 /// different key. The AAD is defense-in-depth: it makes the binding
850 /// explicit rather than implicit in the KDF.
851 #[test]
852 fn wrong_recipient_cannot_decrypt_aead_fails() {
853 let alice = Identity::generate(&mut rng(1)).unwrap();
854 let bob = Identity::generate(&mut rng(2)).unwrap();
855 let charlie = Identity::generate(&mut rng(3)).unwrap();
857 let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"hi bob").unwrap();
858 assert_eq!(
859 decrypt(&charlie, &blob).unwrap_err(),
860 CryptoError::Decrypt,
861 "wrong recipient must fail AEAD — key derivation binds the recipient"
862 );
863 }
865 /// Tampering with the version byte causes failure via the explicit
866 /// version check (`blob[0] != WIRE_VERSION`) before the AEAD even runs.
867 /// The AAD includes the version as defense-in-depth, but the explicit
868 /// check fires first.
869 #[test]
870 fn tampered_version_byte_is_rejected() {
871 let alice = Identity::generate(&mut rng(1)).unwrap();
872 let bob = Identity::generate(&mut rng(2)).unwrap();
873 let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
874 blob[0] ^= 0xff;
875 assert_eq!(
876 decrypt(&bob, &blob).unwrap_err(),
877 CryptoError::Decrypt,
878 "version byte tampering is rejected"
879 );
880 }
882 /// Tampering with the ephemeral pubkey causes key derivation to produce
883 /// a different key, so the AEAD tag check fails. The AAD additionally
884 /// authenticates the eph_pub, but the KDF mismatch is what actually
885 /// catches this.
886 #[test]
887 fn tampered_ephemeral_pubkey_is_rejected() {
888 let alice = Identity::generate(&mut rng(1)).unwrap();
889 let bob = Identity::generate(&mut rng(2)).unwrap();
890 let mut blob = encrypt(&mut rng(3), &alice, &bob.public_key(), b"hi").unwrap();
891 blob[5] ^= 0x01;
892 assert!(decrypt(&bob, &blob).is_err());
893 }