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 nostr_sdk::prelude::*;
14use rusqlite::params;
15
16/// Role of a stored wrap key. Recorded so the deletion path can label
17/// audit logs and so a future feature could selectively retain/purge by
18/// role (e.g. "drop self-send keys after N days").
19#[repr(i64)]
20#[derive(Copy, Clone, Debug, PartialEq, Eq)]
21pub enum WrapRole {
22    /// First-attempt wrap delivered to the recipient.
23    Recipient = 0,
24    /// Wrap delivered to our own inbox for multi-device recovery.
25    SelfSend = 1,
26    /// Retry wrap (used when an earlier attempt produced a different wrap
27    /// event that may also be sitting on some relay).
28    Retry = 2,
29}
30
31impl WrapRole {
32    fn from_i64(v: i64) -> Self {
33        match v {
34            1 => Self::SelfSend,
35            2 => Self::Retry,
36            _ => Self::Recipient,
37        }
38    }
39}
40
41#[derive(Clone, Debug)]
42pub struct StoredWrapKey {
43    pub wrap_event_id: EventId,
44    pub rumor_id: EventId,
45    pub recipient_pubkey: PublicKey,
46    pub role: WrapRole,
47    pub secret: SecretKey,
48    /// Relay URLs we attempted at send time. Deletion publishes the
49    /// author-signed NIP-09 back to this same set.
50    pub relay_urls: Vec<String>,
51}
52
53/// Persist a retained ephemeral wrap secret. Idempotent on
54/// `wrap_event_id` so retries that land the same wrap won't duplicate.
55pub fn store_wrap_key(
56    wrap_event_id: &EventId,
57    rumor_id: &EventId,
58    recipient_pubkey: &PublicKey,
59    role: WrapRole,
60    secret: &SecretKey,
61    relay_urls: &[String],
62) -> Result<(), String> {
63    let conn = super::get_write_connection_guard_static()?;
64    let now = std::time::SystemTime::now()
65        .duration_since(std::time::UNIX_EPOCH)
66        .unwrap()
67        .as_secs() as i64;
68    let relays_json = serde_json::to_string(relay_urls)
69        .map_err(|e| format!("Failed to encode relay urls: {}", e))?;
70    conn.execute(
71        "INSERT OR REPLACE INTO nip17_wrap_keys
72            (wrap_event_id, rumor_id, recipient_pubkey, role, secret, relay_urls, created_at)
73         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
74        params![
75            wrap_event_id.to_hex(),
76            rumor_id.to_hex(),
77            recipient_pubkey.to_hex(),
78            role as i64,
79            secret.as_secret_bytes(),
80            relays_json,
81            now,
82        ],
83    )
84    .map_err(|e| format!("Failed to insert wrap key: {}", e))?;
85    crate::log_info!(
86        "[NIP-17 keys] stored {:?} key for rumor {} (wrap {})",
87        role,
88        rumor_id.to_hex(),
89        wrap_event_id.to_hex()
90    );
91    Ok(())
92}
93
94/// Fetch every retained wrap key (recipient + self + retry) for a given
95/// inner rumor id. Used at delete time to construct one NIP-09 per wrap.
96pub fn get_wrap_keys_for_rumor(rumor_id: &EventId) -> Result<Vec<StoredWrapKey>, String> {
97    let conn = super::get_db_connection_guard_static()?;
98    let mut stmt = conn
99        .prepare(
100            "SELECT wrap_event_id, rumor_id, recipient_pubkey, role, secret, relay_urls
101             FROM nip17_wrap_keys WHERE rumor_id = ?1",
102        )
103        .map_err(|e| format!("Failed to prepare query: {}", e))?;
104
105    let rows = stmt
106        .query_map(params![rumor_id.to_hex()], |row| {
107            let wrap_hex: String = row.get(0)?;
108            let rumor_hex: String = row.get(1)?;
109            let recipient_hex: String = row.get(2)?;
110            let role_i: i64 = row.get(3)?;
111            let secret_blob: Vec<u8> = row.get(4)?;
112            let relays_json: String = row.get(5)?;
113            Ok((wrap_hex, rumor_hex, recipient_hex, role_i, secret_blob, relays_json))
114        })
115        .map_err(|e| format!("Failed to query wrap keys: {}", e))?;
116
117    let mut out = Vec::new();
118    for row_res in rows {
119        let (wrap_hex, rumor_hex, recipient_hex, role_i, secret_blob, relays_json) =
120            row_res.map_err(|e| format!("Row read error: {}", e))?;
121        let wrap_event_id =
122            EventId::from_hex(&wrap_hex).map_err(|e| format!("Bad wrap id: {}", e))?;
123        let rumor_id_parsed =
124            EventId::from_hex(&rumor_hex).map_err(|e| format!("Bad rumor id: {}", e))?;
125        let recipient_pubkey =
126            PublicKey::from_hex(&recipient_hex).map_err(|e| format!("Bad pubkey: {}", e))?;
127        let secret =
128            SecretKey::from_slice(&secret_blob).map_err(|e| format!("Bad secret: {}", e))?;
129        let relay_urls: Vec<String> = serde_json::from_str(&relays_json)
130            .map_err(|e| format!("Bad relay urls: {}", e))?;
131        out.push(StoredWrapKey {
132            wrap_event_id,
133            rumor_id: rumor_id_parsed,
134            recipient_pubkey,
135            role: WrapRole::from_i64(role_i),
136            secret,
137            relay_urls,
138        });
139    }
140    Ok(out)
141}
142
143/// Cheap existence check: do we hold any retained wrap key for this
144/// rumor id? Used by the UI to gate the delete-message control so we
145/// don't tease users with a button we can't actually fulfil.
146pub fn has_wrap_keys_for_rumor(rumor_id: &EventId) -> Result<bool, String> {
147    let conn = super::get_db_connection_guard_static()?;
148    conn.query_row(
149        "SELECT EXISTS(SELECT 1 FROM nip17_wrap_keys WHERE rumor_id = ?1)",
150        params![rumor_id.to_hex()],
151        |row| row.get::<_, bool>(0),
152    )
153    .map_err(|e| format!("Failed to check wrap keys: {}", e))
154}
155
156/// Drop wrap-key rows after the corresponding NIP-09 deletions have
157/// been broadcast. Caller passes the wrap event ids it actually deleted
158/// so partial-success scenarios don't accidentally drop keys still
159/// useful for retry.
160pub fn purge_wrap_keys(wrap_event_ids: &[EventId]) -> Result<(), String> {
161    if wrap_event_ids.is_empty() {
162        return Ok(());
163    }
164    let conn = super::get_write_connection_guard_static()?;
165    let placeholders = std::iter::repeat("?")
166        .take(wrap_event_ids.len())
167        .collect::<Vec<_>>()
168        .join(",");
169    let sql = format!(
170        "DELETE FROM nip17_wrap_keys WHERE wrap_event_id IN ({})",
171        placeholders,
172    );
173    let hex_strings: Vec<String> = wrap_event_ids.iter().map(|id| id.to_hex()).collect();
174    let params_dyn: Vec<&dyn rusqlite::ToSql> = hex_strings
175        .iter()
176        .map(|s| s as &dyn rusqlite::ToSql)
177        .collect();
178    conn.execute(&sql, params_dyn.as_slice())
179        .map_err(|e| format!("Failed to purge wrap keys: {}", e))?;
180    Ok(())
181}