Skip to main content

vector_core/db/
settings.rs

1//! Settings key-value store operations.
2
3/// Get a SQL setting by key.
4pub fn get_sql_setting(key: String) -> Result<Option<String>, String> {
5    let conn = super::get_db_connection_guard_static()?;
6    let result: Option<String> = conn.query_row(
7        "SELECT value FROM settings WHERE key = ?1",
8        rusqlite::params![key],
9        |row| row.get(0),
10    ).ok();
11    Ok(result)
12}
13
14/// Set a SQL setting key-value pair.
15pub fn set_sql_setting(key: String, value: String) -> Result<(), String> {
16    let conn = super::get_write_connection_guard_static()?;
17    conn.execute(
18        "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
19        rusqlite::params![key, value],
20    ).map_err(|e| format!("Failed to set setting: {}", e))?;
21    Ok(())
22}
23
24/// Monotonically advance a numeric setting in ONE statement — the stored
25/// value only ever grows. For reconcile cursors and similar floors, where a
26/// read-modify-write window would let a stale or concurrent writer regress
27/// the value.
28pub fn advance_u64_setting(key: String, value: u64) -> Result<(), String> {
29    let conn = super::get_write_connection_guard_static()?;
30    conn.execute(
31        "INSERT INTO settings (key, value) VALUES (?1, ?2)
32         ON CONFLICT(key) DO UPDATE SET value = excluded.value
33         WHERE CAST(excluded.value AS INTEGER) > CAST(value AS INTEGER)",
34        rusqlite::params![key, value.to_string()],
35    ).map_err(|e| format!("Failed to advance setting: {}", e))?;
36    Ok(())
37}
38
39/// Remove a setting by key.
40pub fn remove_setting(key: &str) -> Result<(), String> {
41    let conn = super::get_write_connection_guard_static()?;
42    conn.execute("DELETE FROM settings WHERE key = ?1", rusqlite::params![key])
43        .map_err(|e| format!("Failed to remove setting: {}", e))?;
44    Ok(())
45}
46
47/// Get the stored private key (bech32 nsec).
48pub fn get_pkey() -> Result<Option<String>, String> {
49    let conn = super::get_db_connection_guard_static()?;
50    Ok(conn.query_row(
51        "SELECT value FROM settings WHERE key = 'pkey'",
52        [],
53        |row| row.get(0),
54    ).ok())
55}
56
57/// Set the stored private key.
58pub fn set_pkey(pkey: &str) -> Result<(), String> {
59    let conn = super::get_write_connection_guard_static()?;
60    conn.execute(
61        "INSERT OR REPLACE INTO settings (key, value) VALUES ('pkey', ?1)",
62        rusqlite::params![pkey],
63    ).map_err(|e| format!("Failed to set pkey: {}", e))?;
64    Ok(())
65}
66
67/// Get the stored seed phrase (may be encrypted).
68pub fn get_seed() -> Result<Option<String>, String> {
69    let conn = super::get_db_connection_guard_static()?;
70    Ok(conn.query_row(
71        "SELECT value FROM settings WHERE key = 'seed'",
72        [],
73        |row| row.get(0),
74    ).ok())
75}
76
77/// Set the seed phrase (should be encrypted before calling).
78pub fn set_seed(seed: &str) -> Result<(), String> {
79    let conn = super::get_write_connection_guard_static()?;
80    conn.execute(
81        "INSERT OR REPLACE INTO settings (key, value) VALUES ('seed', ?1)",
82        rusqlite::params![seed],
83    ).map_err(|e| format!("Failed to set seed: {}", e))?;
84    Ok(())
85}
86
87/// Atomically commit the four settings written during new-account setup:
88/// the (possibly-encrypted) pkey, the `encryption_enabled` flag, the
89/// `security_type` (only when encrypted), and the (already-encrypted) seed
90/// phrase. Wrapping these in a single transaction makes the new-account
91/// flow crash-safe: either all four land or none do. The previous design
92/// wrote them through four separate `set_sql_setting` calls, which left a
93/// window where pkey was persisted but `encryption_enabled` was not — the
94/// next boot would then mis-interpret the encrypted blob as plaintext nsec
95/// and brick the account.
96///
97/// `security_type` is `Some(_)` for encrypted accounts and `None` for
98/// skip-encryption flows (passing `Some("")` would write an empty string,
99/// which `resolve_encryption_enabled` treats as encrypted — not what we
100/// want for the skip path).
101pub fn commit_account_setup(
102    pkey: &str,
103    encryption_enabled: bool,
104    security_type: Option<&str>,
105    encrypted_seed: Option<&str>,
106    biometric_wrap: Option<&str>,
107) -> Result<(), String> {
108    let mut conn = super::get_write_connection_guard_static()?;
109    let tx = conn.transaction()
110        .map_err(|e| format!("Failed to begin tx: {}", e))?;
111    tx.execute(
112        "INSERT OR REPLACE INTO settings (key, value) VALUES ('pkey', ?1)",
113        rusqlite::params![pkey],
114    ).map_err(|e| format!("Failed to set pkey: {}", e))?;
115    tx.execute(
116        "INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', ?1)",
117        rusqlite::params![if encryption_enabled { "true" } else { "false" }],
118    ).map_err(|e| format!("Failed to set encryption_enabled: {}", e))?;
119    if let Some(st) = security_type {
120        tx.execute(
121            "INSERT OR REPLACE INTO settings (key, value) VALUES ('security_type', ?1)",
122            rusqlite::params![st],
123        ).map_err(|e| format!("Failed to set security_type: {}", e))?;
124    } else {
125        // Skip path: ensure no stale security_type from a previous setup
126        // attempt lingers (would mis-route `resolve_encryption_enabled`).
127        tx.execute(
128            "DELETE FROM settings WHERE key = 'security_type'",
129            [],
130        ).map_err(|e| format!("Failed to clear security_type: {}", e))?;
131    }
132    if let Some(seed) = encrypted_seed {
133        tx.execute(
134            "INSERT OR REPLACE INTO settings (key, value) VALUES ('seed', ?1)",
135            rusqlite::params![seed],
136        ).map_err(|e| format!("Failed to set seed: {}", e))?;
137    }
138    // Write an explicit signer_type='local' row so the post-migration
139    // invariant "every account has a discriminator on disk" holds for
140    // freshly-created local accounts too (the migration only backfills
141    // pre-existing rows).
142    tx.execute(
143        "INSERT OR REPLACE INTO settings (key, value) VALUES ('signer_type', 'local')",
144        [],
145    ).map_err(|e| format!("Failed to set signer_type: {}", e))?;
146    // Biometric-only mode: the wrapped vault key is the account's SOLE
147    // credential, so it must land in the SAME transaction that locks the
148    // store to it — a crash between commit and a separate write would leave
149    // the account permanently unrecoverable. None purges any stale wrap.
150    match biometric_wrap {
151        Some(w) => {
152            tx.execute(
153                "INSERT OR REPLACE INTO settings (key, value) VALUES ('biometric_wrapped_key', ?1)",
154                rusqlite::params![w],
155            ).map_err(|e| format!("Failed to set biometric wrap: {}", e))?;
156        }
157        None => {
158            tx.execute(
159                "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
160                [],
161            ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
162        }
163    }
164
165    tx.commit().map_err(|e| format!("Failed to commit tx: {}", e))?;
166    Ok(())
167}
168
169// ============================================================================
170// NIP-46 remote-signer settings (added in migration 27)
171// ============================================================================
172//
173// Three keys back the bunker login flow:
174//   - `signer_type`         — "local" | "bunker"
175//   - `bunker_url`          — `bunker://...` URI, encrypted-at-rest when the
176//                             account uses pin/pass encryption (same path as
177//                             pkey). Contains the connection secret.
178//   - `bunker_remote_pubkey`— signer pubkey, plaintext (routing info only).
179//
180// The `bunker_url` getter/setter is `async` because `maybe_encrypt`/
181// `maybe_decrypt` await on Argon2id key derivation when the user is logged
182// into an encrypted account. The two plaintext fields stay sync.
183
184/// Read the active signer kind from settings. Missing rows pre-date migration
185/// 27 and are treated as `"local"` so pre-NIP-46 accounts behave unchanged.
186pub fn get_signer_type() -> Result<String, String> {
187    let conn = super::get_db_connection_guard_static()?;
188    Ok(conn.query_row(
189        "SELECT value FROM settings WHERE key = 'signer_type'",
190        [],
191        |row| row.get::<_, String>(0),
192    ).unwrap_or_else(|_| "local".to_string()))
193}
194
195/// Persist the signer kind. Accepts the discriminator's `as_setting_str()`
196/// form ("local" or "bunker"); other values are accepted but `get_signer_type`
197/// will treat them as `local` downstream.
198pub fn set_signer_type(value: &str) -> Result<(), String> {
199    let conn = super::get_write_connection_guard_static()?;
200    conn.execute(
201        "INSERT OR REPLACE INTO settings (key, value) VALUES ('signer_type', ?1)",
202        rusqlite::params![value],
203    ).map_err(|e| format!("Failed to set signer_type: {}", e))?;
204    Ok(())
205}
206
207/// Read the `bunker://` URL, decrypting it if the account uses encryption.
208/// Returns `Ok(None)` for local accounts (no row), or when decryption fails
209/// against an obviously-encrypted blob (likely the user hasn't unlocked yet).
210pub async fn get_bunker_url() -> Result<Option<String>, String> {
211    let raw: Option<String> = {
212        let conn = super::get_db_connection_guard_static()?;
213        conn.query_row(
214            "SELECT value FROM settings WHERE key = 'bunker_url'",
215            [],
216            |row| row.get::<_, String>(0),
217        ).ok()
218    };
219    match raw {
220        Some(s) => match crate::crypto::maybe_decrypt(s).await {
221            Ok(plain) => Ok(Some(plain)),
222            Err(_) => Err("bunker_url decryption failed (account locked?)".into()),
223        },
224        None => Ok(None),
225    }
226}
227
228/// Persist the `bunker://` URL, encrypting if the account uses encryption.
229/// The plaintext form is never written to disk for encrypted accounts.
230pub async fn set_bunker_url(url: &str) -> Result<(), String> {
231    let stored = crate::crypto::maybe_encrypt(url.to_string()).await;
232    let conn = super::get_write_connection_guard_static()?;
233    conn.execute(
234        "INSERT OR REPLACE INTO settings (key, value) VALUES ('bunker_url', ?1)",
235        rusqlite::params![stored],
236    ).map_err(|e| format!("Failed to set bunker_url: {}", e))?;
237    Ok(())
238}
239
240/// Read the cached remote signer pubkey (hex). Plaintext on disk — it's
241/// public-key material with no secrecy implications, and keeping it readable
242/// before unlock lets the UI display "Connected to <pubkey>" on the locked
243/// account picker without prompting for a password.
244pub fn get_bunker_remote_pubkey() -> Result<Option<String>, String> {
245    let conn = super::get_db_connection_guard_static()?;
246    Ok(conn.query_row(
247        "SELECT value FROM settings WHERE key = 'bunker_remote_pubkey'",
248        [],
249        |row| row.get::<_, String>(0),
250    ).ok())
251}
252
253/// Persist the cached remote signer pubkey (hex form). Updated after each
254/// successful bunker bootstrap — the bootstrap response carries the canonical
255/// pubkey, which may differ from any user-supplied form.
256pub fn set_bunker_remote_pubkey(pubkey_hex: &str) -> Result<(), String> {
257    let conn = super::get_write_connection_guard_static()?;
258    conn.execute(
259        "INSERT OR REPLACE INTO settings (key, value) VALUES ('bunker_remote_pubkey', ?1)",
260        rusqlite::params![pubkey_hex],
261    ).map_err(|e| format!("Failed to set bunker_remote_pubkey: {}", e))?;
262    Ok(())
263}
264
265/// Atomically commit the four settings written during *bunker* new-account
266/// setup: the (possibly-encrypted) client keypair pkey, `encryption_enabled`,
267/// `security_type`, plus `signer_type='bunker'`, the (possibly-encrypted)
268/// `bunker_url`, and the plaintext `bunker_remote_pubkey`. Wraps the whole
269/// commit in a transaction for the same reason as `commit_account_setup` —
270/// a half-written bunker account would brick login.
271///
272/// The seed is intentionally absent: bunker accounts have no local mnemonic
273/// (the user's nsec lives on the remote signer; we only hold a client keypair
274/// with no recovery phrase).
275pub fn commit_bunker_account_setup(
276    pkey: &str,
277    encryption_enabled: bool,
278    security_type: Option<&str>,
279    bunker_url_stored: &str,
280    bunker_remote_pubkey_hex: &str,
281    biometric_wrap: Option<&str>,
282) -> Result<(), String> {
283    let mut conn = super::get_write_connection_guard_static()?;
284    let tx = conn.transaction()
285        .map_err(|e| format!("Failed to begin tx: {}", e))?;
286    tx.execute(
287        "INSERT OR REPLACE INTO settings (key, value) VALUES ('pkey', ?1)",
288        rusqlite::params![pkey],
289    ).map_err(|e| format!("Failed to set pkey: {}", e))?;
290    tx.execute(
291        "INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', ?1)",
292        rusqlite::params![if encryption_enabled { "true" } else { "false" }],
293    ).map_err(|e| format!("Failed to set encryption_enabled: {}", e))?;
294    if let Some(st) = security_type {
295        tx.execute(
296            "INSERT OR REPLACE INTO settings (key, value) VALUES ('security_type', ?1)",
297            rusqlite::params![st],
298        ).map_err(|e| format!("Failed to set security_type: {}", e))?;
299    } else {
300        tx.execute(
301            "DELETE FROM settings WHERE key = 'security_type'",
302            [],
303        ).map_err(|e| format!("Failed to clear security_type: {}", e))?;
304    }
305    tx.execute(
306        "INSERT OR REPLACE INTO settings (key, value) VALUES ('signer_type', 'bunker')",
307        [],
308    ).map_err(|e| format!("Failed to set signer_type: {}", e))?;
309    tx.execute(
310        "INSERT OR REPLACE INTO settings (key, value) VALUES ('bunker_url', ?1)",
311        rusqlite::params![bunker_url_stored],
312    ).map_err(|e| format!("Failed to set bunker_url: {}", e))?;
313    tx.execute(
314        "INSERT OR REPLACE INTO settings (key, value) VALUES ('bunker_remote_pubkey', ?1)",
315        rusqlite::params![bunker_remote_pubkey_hex],
316    ).map_err(|e| format!("Failed to set bunker_remote_pubkey: {}", e))?;
317    // Drop any stale seed from a previous local-account setup on this DB.
318    tx.execute("DELETE FROM settings WHERE key = 'seed'", [])
319        .map_err(|e| format!("Failed to clear stale seed: {}", e))?;
320    // Biometric-only mode: the wrapped vault key is the account's SOLE
321    // credential, so it must land in the SAME transaction that locks the
322    // store to it — a crash between commit and a separate write would leave
323    // the account permanently unrecoverable. None purges any stale wrap.
324    match biometric_wrap {
325        Some(w) => {
326            tx.execute(
327                "INSERT OR REPLACE INTO settings (key, value) VALUES ('biometric_wrapped_key', ?1)",
328                rusqlite::params![w],
329            ).map_err(|e| format!("Failed to set biometric wrap: {}", e))?;
330        }
331        None => {
332            tx.execute(
333                "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
334                [],
335            ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
336        }
337    }
338
339    tx.commit().map_err(|e| format!("Failed to commit tx: {}", e))?;
340    Ok(())
341}
342
343// ============================================================================
344// NIP-55 offline-signer settings
345// ============================================================================
346//
347// A NIP-55 (Amber) account keeps NOTHING secret on this device — not even the
348// client keypair a bunker account holds. So there is no `pkey` row at all; the
349// only account-identifying material is public:
350//   - `signer_type`         — "nip55"
351//   - `nip55_user_pubkey`   — identity pubkey hex, plaintext (public material;
352//                             lets the locked account picker render the npub
353//                             pre-unlock, same as `bunker_remote_pubkey`).
354//   - `nip55_signer_package`— the signer app's Android package name, plaintext.
355//
356// `encryption_enabled`/`security_type` still apply, but they gate ONLY the
357// local at-rest DB encryption (messages Vector stores) — orthogonal to signing,
358// which never touches this device's storage.
359
360/// Read the cached NIP-55 identity pubkey (hex). Plaintext on disk (public-key
361/// material); readable before unlock so the account picker can show the npub.
362pub fn get_nip55_user_pubkey() -> Result<Option<String>, String> {
363    let conn = super::get_db_connection_guard_static()?;
364    Ok(conn.query_row(
365        "SELECT value FROM settings WHERE key = 'nip55_user_pubkey'",
366        [],
367        |row| row.get::<_, String>(0),
368    ).ok())
369}
370
371/// Persist the NIP-55 identity pubkey (hex).
372pub fn set_nip55_user_pubkey(pubkey_hex: &str) -> Result<(), String> {
373    let conn = super::get_write_connection_guard_static()?;
374    conn.execute(
375        "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_user_pubkey', ?1)",
376        rusqlite::params![pubkey_hex],
377    ).map_err(|e| format!("Failed to set nip55_user_pubkey: {}", e))?;
378    Ok(())
379}
380
381/// Read the paired signer app's Android package name. Pinned on every intent +
382/// as the ContentResolver authority so a second signer app can't intercept.
383pub fn get_nip55_signer_package() -> Result<Option<String>, String> {
384    let conn = super::get_db_connection_guard_static()?;
385    Ok(conn.query_row(
386        "SELECT value FROM settings WHERE key = 'nip55_signer_package'",
387        [],
388        |row| row.get::<_, String>(0),
389    ).ok())
390}
391
392/// Persist the paired signer app's package name. Updated on re-pair.
393pub fn set_nip55_signer_package(package: &str) -> Result<(), String> {
394    let conn = super::get_write_connection_guard_static()?;
395    conn.execute(
396        "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_signer_package', ?1)",
397        rusqlite::params![package],
398    ).map_err(|e| format!("Failed to set nip55_signer_package: {}", e))?;
399    Ok(())
400}
401
402/// Atomically commit NIP-55 new-account setup: `encryption_enabled`,
403/// `security_type`, `signer_type='nip55'`, and the two plaintext public fields.
404/// No `pkey` is written (nothing secret exists), and any stale key material
405/// from a prior local/bunker setup on this DB is scrubbed so login can't
406/// mis-route through a leftover pkey/bunker row. Transactional for the same
407/// reason as the sibling commits — a half-written account bricks login.
408pub fn commit_nip55_account_setup(
409    user_pubkey_hex: &str,
410    signer_package: &str,
411    encryption_enabled: bool,
412    security_type: Option<&str>,
413    biometric_wrap: Option<&str>,
414    pin_canary: Option<&str>,
415) -> Result<(), String> {
416    let mut conn = super::get_write_connection_guard_static()?;
417    let tx = conn.transaction()
418        .map_err(|e| format!("Failed to begin tx: {}", e))?;
419    tx.execute(
420        "INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', ?1)",
421        rusqlite::params![if encryption_enabled { "true" } else { "false" }],
422    ).map_err(|e| format!("Failed to set encryption_enabled: {}", e))?;
423    if let Some(st) = security_type {
424        tx.execute(
425            "INSERT OR REPLACE INTO settings (key, value) VALUES ('security_type', ?1)",
426            rusqlite::params![st],
427        ).map_err(|e| format!("Failed to set security_type: {}", e))?;
428    } else {
429        tx.execute(
430            "DELETE FROM settings WHERE key = 'security_type'",
431            [],
432        ).map_err(|e| format!("Failed to clear security_type: {}", e))?;
433    }
434    tx.execute(
435        "INSERT OR REPLACE INTO settings (key, value) VALUES ('signer_type', 'nip55')",
436        [],
437    ).map_err(|e| format!("Failed to set signer_type: {}", e))?;
438    tx.execute(
439        "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_user_pubkey', ?1)",
440        rusqlite::params![user_pubkey_hex],
441    ).map_err(|e| format!("Failed to set nip55_user_pubkey: {}", e))?;
442    tx.execute(
443        "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_signer_package', ?1)",
444        rusqlite::params![signer_package],
445    ).map_err(|e| format!("Failed to set nip55_signer_package: {}", e))?;
446    // Scrub any secret/bunker material a prior setup on this DB may have left —
447    // a NIP-55 account must never fall back to a stale key at boot.
448    for stale in ["pkey", "seed", "bunker_url", "bunker_remote_pubkey"] {
449        tx.execute(
450            "DELETE FROM settings WHERE key = ?1",
451            rusqlite::params![stale],
452        ).map_err(|e| format!("Failed to clear stale {}: {}", stale, e))?;
453    }
454    // Biometric-only mode: the wrapped vault key is the account's SOLE
455    // credential, so it must land in the SAME transaction that locks the
456    // store to it — a crash between commit and a separate write would leave
457    // the account permanently unrecoverable. None purges any stale wrap.
458    match biometric_wrap {
459        Some(w) => {
460            tx.execute(
461                "INSERT OR REPLACE INTO settings (key, value) VALUES ('biometric_wrapped_key', ?1)",
462                rusqlite::params![w],
463            ).map_err(|e| format!("Failed to set biometric wrap: {}", e))?;
464        }
465        None => {
466            tx.execute(
467                "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
468                [],
469            ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
470        }
471    }
472
473    // The canary is a keyless account's ONLY wrong-PIN detector at boot —
474    // same atomicity rule as the biometric wrap: it lands with the commit or
475    // not at all. None scrubs any stale canary from a prior setup.
476    match pin_canary {
477        Some(c) => {
478            tx.execute(
479                "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_pin_check', ?1)",
480                rusqlite::params![c],
481            ).map_err(|e| format!("Failed to set pin canary: {}", e))?;
482        }
483        None => {
484            tx.execute(
485                "DELETE FROM settings WHERE key = 'nip55_pin_check'",
486                [],
487            ).map_err(|e| format!("Failed to clear pin canary: {}", e))?;
488        }
489    }
490
491    tx.commit().map_err(|e| format!("Failed to commit tx: {}", e))?;
492    Ok(())
493}