@@ -18,7 +18,7 @@ use std::io::Read;
1818use chacha20poly1305::aead::Aead;
1919use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
2020use curve25519_dalek::edwards::CompressedEdwardsY;
21−use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
21+use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
2222use hkdf::Hkdf;
2323use rand_core::{CryptoRng, RngCore};
2424use sha2::{Digest, Sha256, Sha512};
@@ -127,9 +127,17 @@ impl std::error::Error for CryptoError {}
127127///
128128/// `ZeroizeOnDrop` wipes the seed when the value goes away. `Identity` is cloned
129129/// freely (once per encrypt/decrypt), so each of those copies is wiped too.
130+///
131+/// The derived X25519 keys (`x25519_priv`, `x25519_pub`) are computed once at
132+/// construction and cached: `decrypt` is called per-blob in a poll page, and
133+/// recomputing the SHA-512 + Edwards decompress on every call is a measurable
134+/// CPU multiplier under a flood of small blobs. Both are `Zeroizing` so the
135+/// private half is wiped on drop alongside the seed.
130136#[derive(Clone, ZeroizeOnDrop)]
131137pub struct Identity {
132138 pub secret_key: [u8; ED25519_SECRET_LEN],
139+ x25519_priv: [u8; 32],
140+ x25519_pub: [u8; 32],
133141}
134142
135143impl Identity {
@@ -154,7 +162,7 @@ impl Identity {
154162 return Err(CryptoError::BadKeyLength);
155163 }
156164
157− Ok(Identity { secret_key })
165+ Ok(Identity::with_x25519(secret_key))
158166 }
159167
160168 /// Generate a fresh identity from a CSPRNG.
@@ -171,7 +179,7 @@ impl Identity {
171179 let mut secret_key = [0u8; ED25519_SECRET_LEN];
172180 secret_key[..32].copy_from_slice(seed);
173181 secret_key[32..].copy_from_slice(public.as_bytes());
174− Identity { secret_key }
182+ Identity::with_x25519(secret_key)
175183 }
176184
177185 pub fn public_key(&self) -> [u8; ED25519_PUBLIC_LEN] {
@@ -190,6 +198,29 @@ impl Identity {
190198 SigningKey::from_bytes(&seed)
191199 }
192200
201+ /// Compute the cached X25519 keys from the Ed25519 secret key.
202+ fn with_x25519(secret_key: [u8; ED25519_SECRET_LEN]) -> Self {
203+ let x25519_priv = *ed25519_sk_to_x25519(&secret_key);
204+ let x25519_pub = x25519_dalek::x25519(x25519_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
205+ Identity {
206+ secret_key,
207+ x25519_priv,
208+ x25519_pub,
209+ }
210+ }
211+
212+ /// Cached X25519 private key derived from the Ed25519 seed (libsodium
213+ /// `crypto_sign_ed25519_sk_to_curve25519`). Avoids a per-decrypt SHA-512.
214+ fn x25519_private(&self) -> &[u8; 32] {
215+ &self.x25519_priv
216+ }
217+
218+ /// Cached X25519 public key derived from the Ed25519 public key (libsodium
219+ /// `crypto_sign_ed25519_pk_to_curve25519`). Avoids a per-decrypt decompress.
220+ fn x25519_public(&self) -> &[u8; 32] {
221+ &self.x25519_pub
222+ }
223+
193224 /// Sign the auth challenge: `ed25519(sk, "skrepka-auth-v1:" + host + ":" + challenge)`.
194225 /// `server_host` must already be the bare lowercased hostname.
195226 ///
@@ -271,6 +302,14 @@ fn compress(plaintext: &[u8]) -> Vec<u8> {
271302/// *would* overrun is detected rather than silently truncated.
272303fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
273304 let mut decoder = zstd::stream::Decoder::new(data).map_err(|_| CryptoError::Decompress)?;
305+ // Cap the decoder's window buffer so a crafted frame declaring a huge
306+ // windowLog (up to 27 = 128 MiB by default) cannot force a matching
307+ // allocation before the output-size cap even applies. 2^21 = 2 MiB is
308+ // comfortably above `MAX_PLAINTEXT_LEN` (1 MiB), so every legitimate frame
309+ // still decodes; frames declaring a larger window are rejected.
310+ decoder
311+ .window_log_max(21)
312+ .map_err(|_| CryptoError::Decompress)?;
274313 let mut out = Vec::new();
275314 (&mut decoder)
276315 .take(MAX_PLAINTEXT_LEN as u64 + 1)
@@ -386,10 +425,10 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
386425 nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]);
387426 let ciphertext = &blob[33 + NONCE_LEN..];
388427
389− let recip_x_priv = ed25519_sk_to_x25519(&recipient.secret_key);
390− let raw_secret = x25519(&recip_x_priv, &eph_pub)?;
391− let recip_x_pub = ed25519_pk_to_x25519(&recipient.public_key())?;
392− let key = derive_key(&raw_secret, &eph_pub, &recip_x_pub);
428+ let recip_x_priv = recipient.x25519_private();
429+ let raw_secret = x25519(recip_x_priv, &eph_pub)?;
430+ let recip_x_pub = recipient.x25519_public();
431+ let key = derive_key(&raw_secret, &eph_pub, recip_x_pub);
393432
394433 let cipher = XChaCha20Poly1305::new(Key::from_slice(&*key));
395434 let mut inner = Zeroizing::new(
@@ -427,7 +466,7 @@ pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoErr
427466 signed.extend_from_slice(&recipient.public_key());
428467 signed.extend_from_slice(&inner[96..]);
429468 verifying
430− .verify(&signed, &signature)
469+ .verify_strict(&signed, &signature)
431470 .map_err(|_| CryptoError::BadSignature)?;
432471
433472 let plaintext = decompress(compressed)?;