Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/vector-core/src/crypto/guarded_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,10 +454,16 @@ impl GuardedKey {
}

pub fn to_keys(&self) -> Option<nostr_sdk::prelude::Keys> {
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<nostr_sdk::prelude::SecretKey> {
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()
}
}

Expand Down
112 changes: 104 additions & 8 deletions crates/vector-core/src/crypto/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,38 @@ impl GuardedSigner {
.to_keys()
.ok_or_else(|| SignerError::from("Secret key not available"))
}

fn temp_secret(&self) -> Result<SecretKey, SignerError> {
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<nip59::UnwrappedGift, SignerError> {
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 })
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -59,14 +91,14 @@ impl Nip04 for GuardedSigner {
type Error = SignerError;

fn nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Self::Error> {
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<String, Self::Error> {
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)
}
}
Expand All @@ -75,14 +107,14 @@ impl Nip44 for GuardedSigner {
type Error = SignerError;

fn nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Self::Error> {
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<String, Self::Error> {
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)
}
}
Expand Down Expand Up @@ -158,3 +190,67 @@ impl AsyncNip44 for GuardedSigner {
Box::pin(async move { res })
}
}

#[cfg(test)]
mod tests {
use super::*;

fn with_vault_key<R>(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(&not_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(&note).is_err());
});
}
}
11 changes: 7 additions & 4 deletions crates/vector-core/src/event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 74 additions & 0 deletions crates/vector-core/tests/bench_unwrap.rs
Original file line number Diff line number Diff line change
@@ -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<Event> = (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]);
}
Loading