From 78f2dde8a9ce4e074e0fccc2c86d452cee78e050 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 00:16:36 +0000 Subject: [PATCH] perf(crypto): one vault read per gift-wrap unwrap, no keypair derive for ECDH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every GuardedKey read runs 4096..8191 mix rounds by design (~45-75 µs), and the generic nip59 unwrap read the vault once per layer, each time also deriving a keypair it never used. Local accounts now unwrap with a single read and use the bare SecretKey for NIP-04/44, keeping nip59's checks in the same order. Inbound unwrap, median of 8 interleaved launches: 373 µs -> 297 µs (-20%). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01K9tM3F7VFTMcoTN1cmoiuo --- crates/vector-core/src/crypto/guarded_key.rs | 8 +- crates/vector-core/src/crypto/signer.rs | 112 +++++++++++++++++-- crates/vector-core/src/event_handler.rs | 11 +- crates/vector-core/tests/bench_unwrap.rs | 74 ++++++++++++ 4 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 crates/vector-core/tests/bench_unwrap.rs diff --git a/crates/vector-core/src/crypto/guarded_key.rs b/crates/vector-core/src/crypto/guarded_key.rs index 27fa19bb7..64ac34a36 100644 --- a/crates/vector-core/src/crypto/guarded_key.rs +++ b/crates/vector-core/src/crypto/guarded_key.rs @@ -454,10 +454,16 @@ impl GuardedKey { } pub fn to_keys(&self) -> Option { + Some(nostr_sdk::prelude::Keys::new(self.to_secret_key()?)) + } + + /// The bare secret, skipping the keypair derivation `to_keys` pays for — + /// for ECDH-only work (NIP-04/44) that never needs the public half. + pub fn to_secret_key(&self) -> Option { let mut bytes = self.get()?; let result = nostr_sdk::prelude::SecretKey::from_slice(&bytes); bytes.zeroize(); - Some(nostr_sdk::prelude::Keys::new(result.ok()?)) + result.ok() } } diff --git a/crates/vector-core/src/crypto/signer.rs b/crates/vector-core/src/crypto/signer.rs index 0b4fdfba9..e174fff33 100644 --- a/crates/vector-core/src/crypto/signer.rs +++ b/crates/vector-core/src/crypto/signer.rs @@ -31,6 +31,38 @@ impl GuardedSigner { .to_keys() .ok_or_else(|| SignerError::from("Secret key not available")) } + + fn temp_secret(&self) -> Result { + crate::state::MY_SECRET_KEY + .to_secret_key() + .ok_or_else(|| SignerError::from("Secret key not available")) + } + + /// NIP-59 unwrap with one vault read for both layers. The generic + /// `UnwrappedGift::from_gift_wrap` reads the vault per layer, and each read + /// is deliberately expensive. Same checks, same order. + pub fn unwrap_gift_wrap(&self, gift_wrap: &Event) -> Result { + if gift_wrap.kind != Kind::GiftWrap { + return Err("not a gift wrap".into()); + } + // Authenticate before the secret leaves the vault. + gift_wrap.verify().map_err(SignerError::backend)?; + + let secret = self.temp_secret()?; + let seal = nip44::decrypt(&secret, &gift_wrap.pubkey, &gift_wrap.content) + .map_err(SignerError::backend)?; + let seal = Event::from_json(seal).map_err(SignerError::backend)?; + seal.verify().map_err(SignerError::backend)?; + let rumor = nip44::decrypt(&secret, &seal.pubkey, &seal.content) + .map_err(SignerError::backend)?; + drop(secret); + + let rumor = UnsignedEvent::from_json(rumor).map_err(SignerError::backend)?; + if rumor.pubkey != seal.pubkey { + return Err("rumor author does not match the seal".into()); + } + Ok(nip59::UnwrappedGift { sender: seal.pubkey, rumor }) + } } // --------------------------------------------------------------------------- @@ -59,14 +91,14 @@ impl Nip04 for GuardedSigner { type Error = SignerError; fn nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result { - let keys = self.temp_keys()?; - keys.nip04_encrypt(public_key, content) + let secret = self.temp_secret()?; + nip04::encrypt(&secret, public_key, content) .map_err(SignerError::backend) } fn nip04_decrypt(&self, public_key: &PublicKey, payload: &str) -> Result { - let keys = self.temp_keys()?; - keys.nip04_decrypt(public_key, payload) + let secret = self.temp_secret()?; + nip04::decrypt(&secret, public_key, payload) .map_err(SignerError::backend) } } @@ -75,14 +107,14 @@ impl Nip44 for GuardedSigner { type Error = SignerError; fn nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result { - let keys = self.temp_keys()?; - keys.nip44_encrypt(public_key, content) + let secret = self.temp_secret()?; + nip44::encrypt(&secret, public_key, content, nip44::Version::default()) .map_err(SignerError::backend) } fn nip44_decrypt(&self, public_key: &PublicKey, payload: &str) -> Result { - let keys = self.temp_keys()?; - keys.nip44_decrypt(public_key, payload) + let secret = self.temp_secret()?; + nip44::decrypt(&secret, public_key, payload) .map_err(SignerError::backend) } } @@ -158,3 +190,67 @@ impl AsyncNip44 for GuardedSigner { Box::pin(async move { res }) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn with_vault_key(f: impl FnOnce(&Keys, &GuardedSigner) -> R) -> R { + let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + let me = Keys::generate(); + crate::state::MY_SECRET_KEY.store_from_keys(&me, &[&crate::state::ENCRYPTION_KEY]); + let out = f(&me, &GuardedSigner::new(me.public_key())); + crate::state::MY_SECRET_KEY.clear(&[&crate::state::ENCRYPTION_KEY]); + out + } + + fn rumor(author: &Keys, to: &PublicKey) -> UnsignedEvent { + EventBuilder::new(Kind::PrivateDirectMessage, "hi") + .tag(Tag::public_key(*to)) + .finalize_unsigned(author.public_key()) + } + + #[test] + fn unwrap_matches_nostr_from_gift_wrap() { + with_vault_key(|me, signer| { + let sender = Keys::generate(); + let wrap = nip59::GiftWrapBuilder::new(me.public_key(), rumor(&sender, &me.public_key())) + .finalize(&sender) + .unwrap(); + let ours = signer.unwrap_gift_wrap(&wrap).unwrap(); + let theirs = nip59::UnwrappedGift::from_gift_wrap(me, &wrap).unwrap(); + assert_eq!(ours.sender, theirs.sender); + assert_eq!(ours.rumor.as_json(), theirs.rumor.as_json()); + }); + } + + #[test] + fn unwrap_rejects_what_nostr_rejects() { + with_vault_key(|me, signer| { + let sender = Keys::generate(); + let good = nip59::GiftWrapBuilder::new(me.public_key(), rumor(&sender, &me.public_key())) + .finalize(&sender) + .unwrap(); + + let mut tampered = good.clone(); + tampered.content.push('A'); + assert!(signer.unwrap_gift_wrap(&tampered).is_err(), "outer signature must be checked"); + + let other = Keys::generate(); + let not_mine = nip59::GiftWrapBuilder::new(other.public_key(), rumor(&sender, &other.public_key())) + .finalize(&sender) + .unwrap(); + assert!(signer.unwrap_gift_wrap(¬_mine).is_err()); + + // Seal signed by `sender`, rumor claiming `other`. + let forged = nip59::GiftWrapBuilder::new(me.public_key(), rumor(&other, &me.public_key())) + .finalize(&sender) + .unwrap(); + assert!(nip59::UnwrappedGift::from_gift_wrap(me, &forged).is_err()); + assert!(signer.unwrap_gift_wrap(&forged).is_err(), "rumor author must match the seal"); + + let note = EventBuilder::new(Kind::TextNote, "x").finalize(&sender).unwrap(); + assert!(signer.unwrap_gift_wrap(¬e).is_err()); + }); + } +} diff --git a/crates/vector-core/src/event_handler.rs b/crates/vector-core/src/event_handler.rs index 962c17ed2..b36da00d8 100644 --- a/crates/vector-core/src/event_handler.rs +++ b/crates/vector-core/src/event_handler.rs @@ -393,11 +393,14 @@ pub async fn prepare_event( wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at, }, }; - let (rumor, sender) = match UnwrappedGift::from_gift_wrap_async(&signer, &event).await { - Ok(UnwrappedGift { rumor, sender }) => (rumor, sender), - Err(_) => return PreparedEvent::ErrorSkip { + let unwrapped = match &signer { + crate::signer::ActiveSigner::Local(local) => local.unwrap_gift_wrap(&event).ok(), + _ => UnwrappedGift::from_gift_wrap_async(&signer, &event).await.ok(), + }; + let Some(UnwrappedGift { rumor, sender }) = unwrapped else { + return PreparedEvent::ErrorSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at, - }, + }; }; let unwrap_ns = unwrap_start.elapsed().as_nanos() as u64; diff --git a/crates/vector-core/tests/bench_unwrap.rs b/crates/vector-core/tests/bench_unwrap.rs new file mode 100644 index 000000000..440a72618 --- /dev/null +++ b/crates/vector-core/tests/bench_unwrap.rs @@ -0,0 +1,74 @@ +//! Inbound gift-wrap unwrap cost: every received DM pays it, and a sync pays it +//! once per backlogged wrap. +//! +//! Each `GuardedKey` read is deliberately expensive (randomised 4096..8191 mix +//! rounds per launch), so absolute numbers swing between runs; compare rows from +//! the same run. +//! +//! `#[ignore]`d: a measurement, not an assertion. Run deliberately: +//! cargo test --release -p vector-core --test bench_unwrap -- --ignored --nocapture + +use std::hint::black_box; +use std::time::Instant; + +use nostr_sdk::prelude::*; +use vector_core::state::{ENCRYPTION_KEY, MY_SECRET_KEY}; +use vector_core::GuardedSigner; + +const ITERS: u32 = 2_000; + +fn bench(name: &str, mut f: impl FnMut()) { + for _ in 0..ITERS / 10 { + f(); + } + let mut best = f64::MAX; + for _ in 0..5 { + let t = Instant::now(); + for _ in 0..ITERS { + f(); + } + best = best.min(t.elapsed().as_nanos() as f64 / ITERS as f64); + } + println!("{name:<48} {:>8.1} µs", best / 1000.0); +} + +#[test] +#[ignore = "benchmark, not an assertion"] +fn bench_gift_wrap_unwrap() { + let me = Keys::generate(); + let sender = Keys::generate(); + MY_SECRET_KEY.store_from_keys(&me, &[&ENCRYPTION_KEY]); + let signer = GuardedSigner::new(me.public_key()); + + let rumor = EventBuilder::new(Kind::PrivateDirectMessage, "a typical short DM of a few dozen bytes") + .tag(Tag::public_key(me.public_key())) + .finalize_unsigned(sender.public_key()); + let wraps: Vec = (0..64) + .map(|_| nip59::GiftWrapBuilder::new(me.public_key(), rumor.clone()).finalize(&sender).unwrap()) + .collect(); + let w = &wraps[0]; + + bench("vault get()", || { black_box(MY_SECRET_KEY.get()); }); + bench("vault to_keys()", || { black_box(MY_SECRET_KEY.to_keys()); }); + bench("event.verify()", || { w.verify().unwrap(); }); + bench("to_keys() + nip44 decrypt (signer path before)", || { + let keys = MY_SECRET_KEY.to_keys().unwrap(); + black_box(Nip44::nip44_decrypt(&keys, &w.pubkey, &w.content).unwrap()); + }); + bench("GuardedSigner nip44_decrypt (one layer)", || { + black_box(Nip44::nip44_decrypt(&signer, &w.pubkey, &w.content).unwrap()); + }); + + let mut i = 0; + bench("nip59 UnwrappedGift::from_gift_wrap(GuardedSigner)", || { + i += 1; + black_box(nip59::UnwrappedGift::from_gift_wrap(&signer, &wraps[i % wraps.len()]).unwrap()); + }); + let mut i = 0; + bench("GuardedSigner::unwrap_gift_wrap", || { + i += 1; + black_box(signer.unwrap_gift_wrap(&wraps[i % wraps.len()]).unwrap()); + }); + + MY_SECRET_KEY.clear(&[&ENCRYPTION_KEY]); +}