Skip to main content

vector_core/db/
nip17_keys.rs

1//! NIP-17 ephemeral wrap-key vault.
2//!
3//! NIP-59 gift-wraps each DM with a fresh ephemeral keypair whose secret
4//! is normally discarded immediately after signing. We retain it so the
5//! user can later publish an author-signed NIP-09 deletion against the
6//! kind-1059 wrap event — actually removing the message from inbox
7//! relays rather than relying on "throw the keys away and hope".
8//!
9//! Encryption-at-rest is handled by Vector's per-account database
10//! envelope: ChaCha20 if the account has a password, plaintext if it
11//! doesn't (passwordless accounts are unencrypted by design).
12
13use crate::event_ext::FinalizeUnsignedWithId;
14use nostr_sdk::prelude::{FinalizeEvent, FinalizeUnsignedEvent};
15use nostr_sdk::prelude::*;
16use rusqlite::{params, OptionalExtension};
17
18/// Role of a stored wrap key. Recorded so the deletion path can label
19/// audit logs and so a future feature could selectively retain/purge by
20/// role (e.g. "drop self-send keys after N days").
21#[repr(i64)]
22#[derive(Copy, Clone, Debug, PartialEq, Eq)]
23pub enum WrapRole {
24    /// First-attempt wrap delivered to the recipient.
25    Recipient = 0,
26    /// Wrap delivered to our own inbox for multi-device recovery.
27    SelfSend = 1,
28    /// Retry wrap (used when an earlier attempt produced a different wrap
29    /// event that may also be sitting on some relay).
30    Retry = 2,
31}
32
33impl WrapRole {
34    fn from_i64(v: i64) -> Self {
35        match v {
36            1 => Self::SelfSend,
37            2 => Self::Retry,
38            _ => Self::Recipient,
39        }
40    }
41}
42
43#[derive(Clone, Debug)]
44pub struct StoredWrapKey {
45    pub wrap_event_id: EventId,
46    pub rumor_id: EventId,
47    pub recipient_pubkey: PublicKey,
48    pub role: WrapRole,
49    pub secret: SecretKey,
50    /// Relay URLs we attempted at send time. Deletion publishes the
51    /// author-signed NIP-09 back to this same set.
52    pub relay_urls: Vec<String>,
53}
54
55/// Persist a retained ephemeral wrap secret. Idempotent on
56/// `wrap_event_id` so retries that land the same wrap won't duplicate.
57pub fn store_wrap_key(
58    wrap_event_id: &EventId,
59    rumor_id: &EventId,
60    recipient_pubkey: &PublicKey,
61    role: WrapRole,
62    secret: &SecretKey,
63    relay_urls: &[String],
64) -> Result<(), String> {
65    let conn = super::get_write_connection_guard_static()?;
66    let now = std::time::SystemTime::now()
67        .duration_since(std::time::UNIX_EPOCH)
68        .unwrap()
69        .as_secs() as i64;
70    let relays_json = serde_json::to_string(relay_urls)
71        .map_err(|e| format!("Failed to encode relay urls: {}", e))?;
72    conn.execute(
73        "INSERT OR REPLACE INTO nip17_wrap_keys
74            (wrap_event_id, rumor_id, recipient_pubkey, role, secret, relay_urls, created_at)
75         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
76        params![
77            wrap_event_id.to_hex(),
78            rumor_id.to_hex(),
79            recipient_pubkey.to_hex(),
80            role as i64,
81            secret.as_secret_bytes(),
82            relays_json,
83            now,
84        ],
85    )
86    .map_err(|e| format!("Failed to insert wrap key: {}", e))?;
87    crate::log_info!(
88        "[NIP-17 keys] stored {:?} key for rumor {} (wrap {})",
89        role,
90        rumor_id.to_hex(),
91        wrap_event_id.to_hex()
92    );
93    Ok(())
94}
95
96/// Fetch every retained wrap key (recipient + self + retry) for a given
97/// inner rumor id. Used at delete time to construct one NIP-09 per wrap.
98pub fn get_wrap_keys_for_rumor(rumor_id: &EventId) -> Result<Vec<StoredWrapKey>, String> {
99    let conn = super::get_db_connection_guard_static()?;
100    let mut stmt = conn
101        .prepare(
102            "SELECT wrap_event_id, rumor_id, recipient_pubkey, role, secret, relay_urls
103             FROM nip17_wrap_keys WHERE rumor_id = ?1",
104        )
105        .map_err(|e| format!("Failed to prepare query: {}", e))?;
106
107    let rows = stmt
108        .query_map(params![rumor_id.to_hex()], |row| {
109            let wrap_hex: String = row.get(0)?;
110            let rumor_hex: String = row.get(1)?;
111            let recipient_hex: String = row.get(2)?;
112            let role_i: i64 = row.get(3)?;
113            let secret_blob: Vec<u8> = row.get(4)?;
114            let relays_json: String = row.get(5)?;
115            Ok((wrap_hex, rumor_hex, recipient_hex, role_i, secret_blob, relays_json))
116        })
117        .map_err(|e| format!("Failed to query wrap keys: {}", e))?;
118
119    let mut out = Vec::new();
120    for row_res in rows {
121        let (wrap_hex, rumor_hex, recipient_hex, role_i, secret_blob, relays_json) =
122            row_res.map_err(|e| format!("Row read error: {}", e))?;
123        let wrap_event_id =
124            EventId::from_hex(&wrap_hex).map_err(|e| format!("Bad wrap id: {}", e))?;
125        let rumor_id_parsed =
126            EventId::from_hex(&rumor_hex).map_err(|e| format!("Bad rumor id: {}", e))?;
127        let recipient_pubkey =
128            PublicKey::from_hex(&recipient_hex).map_err(|e| format!("Bad pubkey: {}", e))?;
129        let secret =
130            SecretKey::from_slice(&secret_blob).map_err(|e| format!("Bad secret: {}", e))?;
131        let relay_urls: Vec<String> = serde_json::from_str(&relays_json)
132            .map_err(|e| format!("Bad relay urls: {}", e))?;
133        out.push(StoredWrapKey {
134            wrap_event_id,
135            rumor_id: rumor_id_parsed,
136            recipient_pubkey,
137            role: WrapRole::from_i64(role_i),
138            secret,
139            relay_urls,
140        });
141    }
142    Ok(out)
143}
144
145/// Cheap existence check: do we hold any retained wrap key for this
146/// rumor id? Used by the UI to gate the delete-message control so we
147/// don't tease users with a button we can't actually fulfil.
148pub fn has_wrap_keys_for_rumor(rumor_id: &EventId) -> Result<bool, String> {
149    let conn = super::get_db_connection_guard_static()?;
150    conn.query_row(
151        "SELECT EXISTS(SELECT 1 FROM nip17_wrap_keys WHERE rumor_id = ?1)",
152        params![rumor_id.to_hex()],
153        |row| row.get::<_, bool>(0),
154    )
155    .map_err(|e| format!("Failed to check wrap keys: {}", e))
156}
157
158/// Drop wrap-key rows after the corresponding NIP-09 deletions have
159/// been broadcast. Caller passes the wrap event ids it actually deleted
160/// so partial-success scenarios don't accidentally drop keys still
161/// useful for retry.
162pub fn purge_wrap_keys(wrap_event_ids: &[EventId]) -> Result<(), String> {
163    if wrap_event_ids.is_empty() {
164        return Ok(());
165    }
166    let conn = super::get_write_connection_guard_static()?;
167    let placeholders = std::iter::repeat("?")
168        .take(wrap_event_ids.len())
169        .collect::<Vec<_>>()
170        .join(",");
171    let sql = format!(
172        "DELETE FROM nip17_wrap_keys WHERE wrap_event_id IN ({})",
173        placeholders,
174    );
175    let hex_strings: Vec<String> = wrap_event_ids.iter().map(|id| id.to_hex()).collect();
176    let params_dyn: Vec<&dyn rusqlite::ToSql> = hex_strings
177        .iter()
178        .map(|s| s as &dyn rusqlite::ToSql)
179        .collect();
180    conn.execute(&sql, params_dyn.as_slice())
181        .map_err(|e| format!("Failed to purge wrap keys: {}", e))?;
182    Ok(())
183}
184
185// ============================================================================
186// Retained wrap body — idempotent manual retry
187// ============================================================================
188//
189// The recipient wrap's ephemeral key is retained above for NIP-09 delete. For
190// a byte-identical *resend* (so a relay no-ops the duplicate rather than
191// storing a second copy) we also retain the built wrap EVENT plus its rumor,
192// keyed by the local pending id so Retry can find them from the failed row.
193// A gift wrap can't be rebuilt identically (random ephemeral key + NIP-44
194// nonce + NIP-59 backdated created_at), so the exact bytes must be kept.
195// The body is transient: nulled the instant a relay confirms delivery.
196
197/// Everything needed to republish a failed DM's recipient wrap verbatim.
198pub struct ResendPayload {
199    /// The exact kind-1059 event to republish (same id → relay dedup).
200    pub wrap_event: Event,
201    /// The inner rumor, for finalize + the self-send copy on success.
202    pub rumor: UnsignedEvent,
203    /// The wrap's ephemeral secret (re-associated with the injected wrap;
204    /// republishing a pre-signed event never reads it, but the send path's
205    /// `BuiltGiftWrap` carries it).
206    pub secret: SecretKey,
207    pub recipient_pubkey: PublicKey,
208    /// Relay set attempted at first send (fallback targets).
209    pub relay_urls: Vec<String>,
210    pub rumor_id: EventId,
211}
212
213/// Attach the republishable body to an existing recipient wrap-key row.
214/// Called right after `store_wrap_key` on the first send attempt.
215pub fn stash_resend_payload(
216    wrap_event_id: &EventId,
217    pending_id: &str,
218    wrap_event: &Event,
219    rumor: &UnsignedEvent,
220) -> Result<(), String> {
221    let conn = super::get_write_connection_guard_static()?;
222    conn.execute(
223        "UPDATE nip17_wrap_keys SET wrap_json = ?1, rumor_json = ?2, pending_id = ?3
224         WHERE wrap_event_id = ?4",
225        params![
226            wrap_event.as_json(),
227            rumor.as_json(),
228            pending_id,
229            wrap_event_id.to_hex(),
230        ],
231    )
232    .map_err(|e| format!("Failed to stash resend payload: {}", e))?;
233    Ok(())
234}
235
236/// Load the republishable recipient wrap for a failed message (its id is the
237/// local pending id). `None` when nothing retained — the caller then falls
238/// back to a fresh send. Only rows with a body (still unconfirmed) match.
239pub fn get_resend_payload_by_pending(pending_id: &str) -> Result<Option<ResendPayload>, String> {
240    let conn = super::get_db_connection_guard_static()?;
241    // role 0 = Recipient — the only wrap a manual retry republishes.
242    let row = conn
243        .query_row(
244            "SELECT rumor_id, recipient_pubkey, secret, relay_urls, wrap_json, rumor_json
245             FROM nip17_wrap_keys
246             WHERE pending_id = ?1 AND role = 0
247               AND wrap_json IS NOT NULL AND rumor_json IS NOT NULL
248             LIMIT 1",
249            params![pending_id],
250            |row| {
251                let rumor_hex: String = row.get(0)?;
252                let recipient_hex: String = row.get(1)?;
253                let secret_blob: Vec<u8> = row.get(2)?;
254                let relays_json: String = row.get(3)?;
255                let wrap_json: String = row.get(4)?;
256                let rumor_json: String = row.get(5)?;
257                Ok((rumor_hex, recipient_hex, secret_blob, relays_json, wrap_json, rumor_json))
258            },
259        )
260        .optional()
261        .map_err(|e| format!("Failed to query resend payload: {}", e))?;
262
263    let Some((rumor_hex, recipient_hex, secret_blob, relays_json, wrap_json, rumor_json)) = row
264    else {
265        return Ok(None);
266    };
267    let rumor_id = EventId::from_hex(&rumor_hex).map_err(|e| format!("Bad rumor id: {}", e))?;
268    let recipient_pubkey =
269        PublicKey::from_hex(&recipient_hex).map_err(|e| format!("Bad pubkey: {}", e))?;
270    let secret = SecretKey::from_slice(&secret_blob).map_err(|e| format!("Bad secret: {}", e))?;
271    let relay_urls: Vec<String> =
272        serde_json::from_str(&relays_json).map_err(|e| format!("Bad relay urls: {}", e))?;
273    let wrap_event = Event::from_json(&wrap_json).map_err(|e| format!("Bad wrap json: {}", e))?;
274    let rumor = UnsignedEvent::from_json(&rumor_json).map_err(|e| format!("Bad rumor json: {}", e))?;
275    Ok(Some(ResendPayload {
276        wrap_event,
277        rumor,
278        secret,
279        recipient_pubkey,
280        relay_urls,
281        rumor_id,
282    }))
283}
284
285/// Drop the republishable body once delivery is confirmed (the key row stays
286/// for NIP-09 delete). Keyed by rumor id so both the recipient and any retry
287/// rows for the message are cleared together.
288pub fn clear_resend_payload(rumor_id: &EventId) -> Result<(), String> {
289    let conn = super::get_write_connection_guard_static()?;
290    conn.execute(
291        "UPDATE nip17_wrap_keys SET wrap_json = NULL, rumor_json = NULL WHERE rumor_id = ?1",
292        params![rumor_id.to_hex()],
293    )
294    .map_err(|e| format!("Failed to clear resend payload: {}", e))?;
295    Ok(())
296}
297
298/// Backstop: null retained bodies older than `max_age_secs` so a pile of
299/// never-retried reds can't grow unbounded. The key row (NIP-09) survives.
300/// Returns how many bodies were reaped.
301pub fn prune_stale_resend_payloads(max_age_secs: i64) -> Result<usize, String> {
302    let conn = super::get_write_connection_guard_static()?;
303    let now = std::time::SystemTime::now()
304        .duration_since(std::time::UNIX_EPOCH)
305        .unwrap()
306        .as_secs() as i64;
307    let cutoff = now - max_age_secs;
308    let n = conn
309        .execute(
310            "UPDATE nip17_wrap_keys SET wrap_json = NULL, rumor_json = NULL
311             WHERE wrap_json IS NOT NULL AND created_at < ?1",
312            params![cutoff],
313        )
314        .map_err(|e| format!("Failed to prune resend payloads: {}", e))?;
315    Ok(n)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(81000);
323
324    fn make_test_npub(n: u32) -> String {
325        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
326        let mut payload = vec![b'q'; 58];
327        let mut x = n as u64;
328        let mut i = 58;
329        while x > 0 && i > 0 {
330            i -= 1;
331            payload[i] = BECH32[(x as usize) % 32];
332            x /= 32;
333        }
334        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
335    }
336
337    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
338        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
339        crate::db::close_database();
340        crate::db::clear_id_caches();
341        let tmp = tempfile::tempdir().unwrap();
342        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
343        let account = make_test_npub(n);
344        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
345        crate::db::set_app_data_dir(tmp.path().to_path_buf());
346        crate::db::set_current_account(account.clone()).unwrap();
347        crate::db::init_database(&account).unwrap();
348        (tmp, guard)
349    }
350
351    fn sample() -> (Event, UnsignedEvent, Keys, PublicKey) {
352        let ephemeral = Keys::generate();
353        let sender = Keys::generate();
354        let recipient = Keys::generate();
355        // Stand-in wrap: any valid signed event — the id is what must survive.
356        let wrap = EventBuilder::text_note("wrap").finalize(&ephemeral).unwrap();
357        let rumor = EventBuilder::new(Kind::PrivateDirectMessage, "hello")
358            .tag(Tag::public_key(recipient.public_key()))
359            .finalize_unsigned_with_id(sender.public_key());
360        (wrap, rumor, ephemeral, recipient.public_key())
361    }
362
363    #[test]
364    fn resend_payload_round_trips_and_clears() {
365        let (_tmp, _guard) = init_test_db();
366        let (wrap, rumor, ephemeral, recipient) = sample();
367        let rumor_id = rumor.id.unwrap();
368        let relays = vec!["wss://relay.one".to_string(), "wss://relay.two".to_string()];
369
370        store_wrap_key(&wrap.id, &rumor_id, &recipient, WrapRole::Recipient,
371            ephemeral.secret_key(), &relays).unwrap();
372        stash_resend_payload(&wrap.id, "pending-42", &wrap, &rumor).unwrap();
373
374        let got = get_resend_payload_by_pending("pending-42").unwrap().expect("payload retained");
375        // The whole point: the wrap republishes byte-identical (same outer id).
376        assert_eq!(got.wrap_event.id, wrap.id);
377        assert_eq!(got.rumor.id, Some(rumor_id));
378        assert_eq!(got.rumor_id, rumor_id);
379        assert_eq!(got.recipient_pubkey, recipient);
380        assert_eq!(got.relay_urls, relays);
381
382        // Confirmed delivery drops the body but keeps the key row for NIP-09.
383        clear_resend_payload(&rumor_id).unwrap();
384        assert!(get_resend_payload_by_pending("pending-42").unwrap().is_none());
385        assert!(has_wrap_keys_for_rumor(&rumor_id).unwrap(), "key row survives NIP-09");
386    }
387
388    #[test]
389    fn no_payload_for_unknown_pending() {
390        let (_tmp, _guard) = init_test_db();
391        assert!(get_resend_payload_by_pending("pending-none").unwrap().is_none());
392    }
393
394    #[test]
395    fn prune_reaps_stale_bodies_but_keeps_keys() {
396        let (_tmp, _guard) = init_test_db();
397        let (wrap, rumor, ephemeral, recipient) = sample();
398        let rumor_id = rumor.id.unwrap();
399        let relays = vec!["wss://relay.one".to_string()];
400        store_wrap_key(&wrap.id, &rumor_id, &recipient, WrapRole::Recipient,
401            ephemeral.secret_key(), &relays).unwrap();
402        stash_resend_payload(&wrap.id, "pending-stale", &wrap, &rumor).unwrap();
403        assert!(get_resend_payload_by_pending("pending-stale").unwrap().is_some());
404
405        // Negative TTL → cutoff in the future → the fresh row's body is reaped.
406        assert_eq!(prune_stale_resend_payloads(-100).unwrap(), 1);
407        assert!(get_resend_payload_by_pending("pending-stale").unwrap().is_none());
408        assert!(has_wrap_keys_for_rumor(&rumor_id).unwrap(), "key row survives prune");
409    }
410}