Build 22: security hardening follow-ups

From the audit's deferred P2 list:
- Global gift-wrap decrypt ceiling (~120/min across all senders) so
  fresh-keypair spam can't force unbounded NIP-44 decrypts. The per-sender
  limit only applied after the decrypt revealed the sender; this caps total
  decrypt work up front.
- NostrStore reads tolerate a poisoned lock (unwrap_or_else into_inner) so a
  single panic can't cascade into taking down all nostr storage for the session.
- Avatar upload/delete reuse the service's keys directly instead of
  round-tripping the secret through a plaintext nsec String.

34 lib tests green.
This commit is contained in:
Claude
2026-06-11 23:23:26 -04:00
parent 413746dde3
commit f2402eb24d
3 changed files with 49 additions and 31 deletions
+9 -21
View File
@@ -2580,13 +2580,9 @@ fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) {
return;
};
let server = service.config.read().nip05_server();
let keys = match service
.nsec()
.and_then(|nsec| nostr_sdk::Keys::parse(&nsec).ok())
{
Some(k) => k,
None => return,
};
// Reuse the service's keys directly — never round-trip the secret through a
// plaintext nsec String to rebuild keys the service already holds.
let keys = service.keys();
claim.checking = true;
claim.message = None;
claim.available = None;
@@ -2626,13 +2622,9 @@ fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) {
return;
};
let server = service.config.read().nip05_server();
let keys = match service
.nsec()
.and_then(|nsec| nostr_sdk::Keys::parse(&nsec).ok())
{
Some(k) => k,
None => return,
};
// Reuse the service's keys directly — never round-trip the secret through a
// plaintext nsec String to rebuild keys the service already holds.
let keys = service.keys();
claim.checking = true;
claim.message = None;
let slot = claim.result.clone();
@@ -2664,13 +2656,9 @@ fn start_avatar_upload(
return;
};
let server = service.config.read().nip05_server();
let keys = match service
.nsec()
.and_then(|nsec| nostr_sdk::Keys::parse(&nsec).ok())
{
Some(k) => k,
None => return,
};
// Reuse the service's keys directly — never round-trip the secret through a
// plaintext nsec String to rebuild keys the service already holds.
let keys = service.keys();
std::thread::spawn(move || {
let res = (|| {
let png = crate::nostr::avatar::process_avatar_file(&path)?;
+30
View File
@@ -134,6 +134,12 @@ impl NostrService {
self.keys.secret_key().to_bech32().ok()
}
/// The service's signing keys, for in-process signing (e.g. NIP-98 auth)
/// without ever serializing the secret to a plaintext `String`.
pub fn keys(&self) -> Keys {
self.keys.clone()
}
/// Fetch a pubkey's published kind-0 profile over the connected relay
/// pool (one shot, short timeout). `Some` means the key is a live nostr
/// identity; `None` means no profile is published (new/anonymous key) or
@@ -254,6 +260,24 @@ impl NostrService {
true
}
/// Global ceiling on gift-wrap decrypt attempts across ALL senders. The
/// per-sender limit only kicks in after the (expensive) NIP-44 decrypt
/// reveals the sender, so an attacker minting unlimited fresh keypairs
/// would otherwise force unbounded decrypts. Bounds total decrypt work to
/// ~2/sec — far above any legitimate inbound rate.
fn allow_global_unwrap(&self) -> bool {
const GLOBAL_PER_MIN: usize = 120;
let now = unix_time();
let mut rate = self.rate.lock();
let hits = rate.entry("\0global".to_string()).or_default();
hits.retain(|t| now - *t < 60);
if hits.len() >= GLOBAL_PER_MIN {
return false;
}
hits.push(now);
true
}
/// Dispatch a payment DM (slatepack + optional note) to a recipient,
/// publishing to their DM relays plus our own relay set.
pub async fn send_payment_dm(
@@ -541,6 +565,12 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
if svc.store.is_processed(&wrap_id) {
return;
}
// 2.5 Global decrypt ceiling: bound total NIP-44 unwrap work regardless of
// sender, so fresh-keypair spam can't burn unbounded CPU/battery. Not marked
// processed — a genuine backlog re-attempts once the window reopens.
if !svc.allow_global_unwrap() {
return;
}
// 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed).
let unwrapped = match client.unwrap_gift_wrap(&event).await {
Ok(u) => u,
+10 -10
View File
@@ -90,7 +90,7 @@ impl NostrStore {
store: &SingleStore<SafeModeDatabase>,
key: &str,
) -> Option<T> {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let reader = env.read().unwrap();
if let Ok(Some(Value::Json(raw))) = store.get(&reader, key) {
return serde_json::from_str(raw).ok();
@@ -100,7 +100,7 @@ impl NostrStore {
fn put_json<T: Serialize>(&self, store: &SingleStore<SafeModeDatabase>, key: &str, value: &T) {
if let Ok(raw) = serde_json::to_string(value) {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let mut writer = env.write().unwrap();
let _ = store.put(&mut writer, key, &Value::Json(&raw));
let _ = writer.commit();
@@ -108,14 +108,14 @@ impl NostrStore {
}
fn delete(&self, store: &SingleStore<SafeModeDatabase>, key: &str) {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let mut writer = env.write().unwrap();
let _ = store.delete(&mut writer, key);
let _ = writer.commit();
}
fn all_json<T: DeserializeOwned>(&self, store: &SingleStore<SafeModeDatabase>) -> Vec<T> {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let reader = env.read().unwrap();
let mut out = vec![];
if let Ok(iter) = store.iter_start(&reader) {
@@ -131,7 +131,7 @@ impl NostrStore {
}
fn clear(&self, store: &SingleStore<SafeModeDatabase>) {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let mut writer = env.write().unwrap();
let _ = store.clear(&mut writer);
let _ = writer.commit();
@@ -205,13 +205,13 @@ impl NostrStore {
// ── processed markers ───────────────────────────────────────────────────
pub fn is_processed(&self, key: &str) -> bool {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let reader = env.read().unwrap();
matches!(self.processed.get(&reader, key), Ok(Some(_)))
}
pub fn mark_processed(&self, key: &str) {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let mut writer = env.write().unwrap();
let _ = self
.processed
@@ -223,7 +223,7 @@ impl NostrStore {
pub fn prune_processed(&self) {
let cutoff = unix_time() - PROCESSED_TTL_SECS;
let stale: Vec<String> = {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let reader = env.read().unwrap();
let mut stale = vec![];
if let Ok(iter) = self.processed.iter_start(&reader) {
@@ -247,7 +247,7 @@ impl NostrStore {
// ── settings ────────────────────────────────────────────────────────────
pub fn last_connected_at(&self) -> Option<i64> {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let reader = env.read().unwrap();
if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, "last_connected_at") {
return Some(v);
@@ -256,7 +256,7 @@ impl NostrStore {
}
pub fn set_last_connected_at(&self, ts: i64) {
let env = self.env.read().unwrap();
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
let mut writer = env.write().unwrap();
let _ = self
.settings