vector-core 0.7.2

Core library for Vector — the single source of truth for all Vector clients, SDKs, and interfaces.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
//! Settings key-value store operations.

/// Get a SQL setting by key.
pub fn get_sql_setting(key: String) -> Result<Option<String>, String> {
    let conn = super::get_db_connection_guard_static()?;
    let result: Option<String> = conn.query_row(
        "SELECT value FROM settings WHERE key = ?1",
        rusqlite::params![key],
        |row| row.get(0),
    ).ok();
    Ok(result)
}

/// Set a SQL setting key-value pair.
pub fn set_sql_setting(key: String, value: String) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
        rusqlite::params![key, value],
    ).map_err(|e| format!("Failed to set setting: {}", e))?;
    Ok(())
}

/// Monotonically advance a numeric setting in ONE statement — the stored
/// value only ever grows. For reconcile cursors and similar floors, where a
/// read-modify-write window would let a stale or concurrent writer regress
/// the value.
pub fn advance_u64_setting(key: String, value: u64) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT INTO settings (key, value) VALUES (?1, ?2)
         ON CONFLICT(key) DO UPDATE SET value = excluded.value
         WHERE CAST(excluded.value AS INTEGER) > CAST(value AS INTEGER)",
        rusqlite::params![key, value.to_string()],
    ).map_err(|e| format!("Failed to advance setting: {}", e))?;
    Ok(())
}

/// Remove a setting by key.
pub fn remove_setting(key: &str) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute("DELETE FROM settings WHERE key = ?1", rusqlite::params![key])
        .map_err(|e| format!("Failed to remove setting: {}", e))?;
    Ok(())
}

/// Get the stored private key (bech32 nsec).
pub fn get_pkey() -> Result<Option<String>, String> {
    let conn = super::get_db_connection_guard_static()?;
    Ok(conn.query_row(
        "SELECT value FROM settings WHERE key = 'pkey'",
        [],
        |row| row.get(0),
    ).ok())
}

/// Set the stored private key.
pub fn set_pkey(pkey: &str) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('pkey', ?1)",
        rusqlite::params![pkey],
    ).map_err(|e| format!("Failed to set pkey: {}", e))?;
    Ok(())
}

/// Get the stored seed phrase (may be encrypted).
pub fn get_seed() -> Result<Option<String>, String> {
    let conn = super::get_db_connection_guard_static()?;
    Ok(conn.query_row(
        "SELECT value FROM settings WHERE key = 'seed'",
        [],
        |row| row.get(0),
    ).ok())
}

/// Set the seed phrase (should be encrypted before calling).
pub fn set_seed(seed: &str) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('seed', ?1)",
        rusqlite::params![seed],
    ).map_err(|e| format!("Failed to set seed: {}", e))?;
    Ok(())
}

/// Atomically commit the four settings written during new-account setup:
/// the (possibly-encrypted) pkey, the `encryption_enabled` flag, the
/// `security_type` (only when encrypted), and the (already-encrypted) seed
/// phrase. Wrapping these in a single transaction makes the new-account
/// flow crash-safe: either all four land or none do. The previous design
/// wrote them through four separate `set_sql_setting` calls, which left a
/// window where pkey was persisted but `encryption_enabled` was not — the
/// next boot would then mis-interpret the encrypted blob as plaintext nsec
/// and brick the account.
///
/// `security_type` is `Some(_)` for encrypted accounts and `None` for
/// skip-encryption flows (passing `Some("")` would write an empty string,
/// which `resolve_encryption_enabled` treats as encrypted — not what we
/// want for the skip path).
pub fn commit_account_setup(
    pkey: &str,
    encryption_enabled: bool,
    security_type: Option<&str>,
    encrypted_seed: Option<&str>,
    biometric_wrap: Option<&str>,
) -> Result<(), String> {
    let mut conn = super::get_write_connection_guard_static()?;
    let tx = conn.transaction()
        .map_err(|e| format!("Failed to begin tx: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('pkey', ?1)",
        rusqlite::params![pkey],
    ).map_err(|e| format!("Failed to set pkey: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', ?1)",
        rusqlite::params![if encryption_enabled { "true" } else { "false" }],
    ).map_err(|e| format!("Failed to set encryption_enabled: {}", e))?;
    if let Some(st) = security_type {
        tx.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES ('security_type', ?1)",
            rusqlite::params![st],
        ).map_err(|e| format!("Failed to set security_type: {}", e))?;
    } else {
        // Skip path: ensure no stale security_type from a previous setup
        // attempt lingers (would mis-route `resolve_encryption_enabled`).
        tx.execute(
            "DELETE FROM settings WHERE key = 'security_type'",
            [],
        ).map_err(|e| format!("Failed to clear security_type: {}", e))?;
    }
    if let Some(seed) = encrypted_seed {
        tx.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES ('seed', ?1)",
            rusqlite::params![seed],
        ).map_err(|e| format!("Failed to set seed: {}", e))?;
    }
    // Write an explicit signer_type='local' row so the post-migration
    // invariant "every account has a discriminator on disk" holds for
    // freshly-created local accounts too (the migration only backfills
    // pre-existing rows).
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('signer_type', 'local')",
        [],
    ).map_err(|e| format!("Failed to set signer_type: {}", e))?;
    // Biometric-only mode: the wrapped vault key is the account's SOLE
    // credential, so it must land in the SAME transaction that locks the
    // store to it — a crash between commit and a separate write would leave
    // the account permanently unrecoverable. None purges any stale wrap.
    match biometric_wrap {
        Some(w) => {
            tx.execute(
                "INSERT OR REPLACE INTO settings (key, value) VALUES ('biometric_wrapped_key', ?1)",
                rusqlite::params![w],
            ).map_err(|e| format!("Failed to set biometric wrap: {}", e))?;
        }
        None => {
            tx.execute(
                "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
                [],
            ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
        }
    }

    tx.commit().map_err(|e| format!("Failed to commit tx: {}", e))?;
    Ok(())
}

// ============================================================================
// NIP-46 remote-signer settings (added in migration 27)
// ============================================================================
//
// Three keys back the bunker login flow:
//   - `signer_type`         — "local" | "bunker"
//   - `bunker_url`          — `bunker://...` URI, encrypted-at-rest when the
//                             account uses pin/pass encryption (same path as
//                             pkey). Contains the connection secret.
//   - `bunker_remote_pubkey`— signer pubkey, plaintext (routing info only).
//
// The `bunker_url` getter/setter is `async` because `maybe_encrypt`/
// `maybe_decrypt` await on Argon2id key derivation when the user is logged
// into an encrypted account. The two plaintext fields stay sync.

/// Read the active signer kind from settings. Missing rows pre-date migration
/// 27 and are treated as `"local"` so pre-NIP-46 accounts behave unchanged.
pub fn get_signer_type() -> Result<String, String> {
    let conn = super::get_db_connection_guard_static()?;
    Ok(conn.query_row(
        "SELECT value FROM settings WHERE key = 'signer_type'",
        [],
        |row| row.get::<_, String>(0),
    ).unwrap_or_else(|_| "local".to_string()))
}

/// Persist the signer kind. Accepts the discriminator's `as_setting_str()`
/// form ("local" or "bunker"); other values are accepted but `get_signer_type`
/// will treat them as `local` downstream.
pub fn set_signer_type(value: &str) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('signer_type', ?1)",
        rusqlite::params![value],
    ).map_err(|e| format!("Failed to set signer_type: {}", e))?;
    Ok(())
}

/// Read the `bunker://` URL, decrypting it if the account uses encryption.
/// Returns `Ok(None)` for local accounts (no row), or when decryption fails
/// against an obviously-encrypted blob (likely the user hasn't unlocked yet).
pub async fn get_bunker_url() -> Result<Option<String>, String> {
    let raw: Option<String> = {
        let conn = super::get_db_connection_guard_static()?;
        conn.query_row(
            "SELECT value FROM settings WHERE key = 'bunker_url'",
            [],
            |row| row.get::<_, String>(0),
        ).ok()
    };
    match raw {
        Some(s) => match crate::crypto::maybe_decrypt(s).await {
            Ok(plain) => Ok(Some(plain)),
            Err(_) => Err("bunker_url decryption failed (account locked?)".into()),
        },
        None => Ok(None),
    }
}

/// Persist the `bunker://` URL, encrypting if the account uses encryption.
/// The plaintext form is never written to disk for encrypted accounts.
pub async fn set_bunker_url(url: &str) -> Result<(), String> {
    let stored = crate::crypto::maybe_encrypt(url.to_string()).await;
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('bunker_url', ?1)",
        rusqlite::params![stored],
    ).map_err(|e| format!("Failed to set bunker_url: {}", e))?;
    Ok(())
}

/// Read the cached remote signer pubkey (hex). Plaintext on disk — it's
/// public-key material with no secrecy implications, and keeping it readable
/// before unlock lets the UI display "Connected to <pubkey>" on the locked
/// account picker without prompting for a password.
pub fn get_bunker_remote_pubkey() -> Result<Option<String>, String> {
    let conn = super::get_db_connection_guard_static()?;
    Ok(conn.query_row(
        "SELECT value FROM settings WHERE key = 'bunker_remote_pubkey'",
        [],
        |row| row.get::<_, String>(0),
    ).ok())
}

/// Persist the cached remote signer pubkey (hex form). Updated after each
/// successful bunker bootstrap — the bootstrap response carries the canonical
/// pubkey, which may differ from any user-supplied form.
pub fn set_bunker_remote_pubkey(pubkey_hex: &str) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('bunker_remote_pubkey', ?1)",
        rusqlite::params![pubkey_hex],
    ).map_err(|e| format!("Failed to set bunker_remote_pubkey: {}", e))?;
    Ok(())
}

/// Atomically commit the four settings written during *bunker* new-account
/// setup: the (possibly-encrypted) client keypair pkey, `encryption_enabled`,
/// `security_type`, plus `signer_type='bunker'`, the (possibly-encrypted)
/// `bunker_url`, and the plaintext `bunker_remote_pubkey`. Wraps the whole
/// commit in a transaction for the same reason as `commit_account_setup` —
/// a half-written bunker account would brick login.
///
/// The seed is intentionally absent: bunker accounts have no local mnemonic
/// (the user's nsec lives on the remote signer; we only hold a client keypair
/// with no recovery phrase).
pub fn commit_bunker_account_setup(
    pkey: &str,
    encryption_enabled: bool,
    security_type: Option<&str>,
    bunker_url_stored: &str,
    bunker_remote_pubkey_hex: &str,
    biometric_wrap: Option<&str>,
) -> Result<(), String> {
    let mut conn = super::get_write_connection_guard_static()?;
    let tx = conn.transaction()
        .map_err(|e| format!("Failed to begin tx: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('pkey', ?1)",
        rusqlite::params![pkey],
    ).map_err(|e| format!("Failed to set pkey: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', ?1)",
        rusqlite::params![if encryption_enabled { "true" } else { "false" }],
    ).map_err(|e| format!("Failed to set encryption_enabled: {}", e))?;
    if let Some(st) = security_type {
        tx.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES ('security_type', ?1)",
            rusqlite::params![st],
        ).map_err(|e| format!("Failed to set security_type: {}", e))?;
    } else {
        tx.execute(
            "DELETE FROM settings WHERE key = 'security_type'",
            [],
        ).map_err(|e| format!("Failed to clear security_type: {}", e))?;
    }
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('signer_type', 'bunker')",
        [],
    ).map_err(|e| format!("Failed to set signer_type: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('bunker_url', ?1)",
        rusqlite::params![bunker_url_stored],
    ).map_err(|e| format!("Failed to set bunker_url: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('bunker_remote_pubkey', ?1)",
        rusqlite::params![bunker_remote_pubkey_hex],
    ).map_err(|e| format!("Failed to set bunker_remote_pubkey: {}", e))?;
    // Drop any stale seed from a previous local-account setup on this DB.
    tx.execute("DELETE FROM settings WHERE key = 'seed'", [])
        .map_err(|e| format!("Failed to clear stale seed: {}", e))?;
    // Biometric-only mode: the wrapped vault key is the account's SOLE
    // credential, so it must land in the SAME transaction that locks the
    // store to it — a crash between commit and a separate write would leave
    // the account permanently unrecoverable. None purges any stale wrap.
    match biometric_wrap {
        Some(w) => {
            tx.execute(
                "INSERT OR REPLACE INTO settings (key, value) VALUES ('biometric_wrapped_key', ?1)",
                rusqlite::params![w],
            ).map_err(|e| format!("Failed to set biometric wrap: {}", e))?;
        }
        None => {
            tx.execute(
                "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
                [],
            ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
        }
    }

    tx.commit().map_err(|e| format!("Failed to commit tx: {}", e))?;
    Ok(())
}

// ============================================================================
// NIP-55 offline-signer settings
// ============================================================================
//
// A NIP-55 (Amber) account keeps NOTHING secret on this device — not even the
// client keypair a bunker account holds. So there is no `pkey` row at all; the
// only account-identifying material is public:
//   - `signer_type`         — "nip55"
//   - `nip55_user_pubkey`   — identity pubkey hex, plaintext (public material;
//                             lets the locked account picker render the npub
//                             pre-unlock, same as `bunker_remote_pubkey`).
//   - `nip55_signer_package`— the signer app's Android package name, plaintext.
//
// `encryption_enabled`/`security_type` still apply, but they gate ONLY the
// local at-rest DB encryption (messages Vector stores) — orthogonal to signing,
// which never touches this device's storage.

/// Read the cached NIP-55 identity pubkey (hex). Plaintext on disk (public-key
/// material); readable before unlock so the account picker can show the npub.
pub fn get_nip55_user_pubkey() -> Result<Option<String>, String> {
    let conn = super::get_db_connection_guard_static()?;
    Ok(conn.query_row(
        "SELECT value FROM settings WHERE key = 'nip55_user_pubkey'",
        [],
        |row| row.get::<_, String>(0),
    ).ok())
}

/// Persist the NIP-55 identity pubkey (hex).
pub fn set_nip55_user_pubkey(pubkey_hex: &str) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_user_pubkey', ?1)",
        rusqlite::params![pubkey_hex],
    ).map_err(|e| format!("Failed to set nip55_user_pubkey: {}", e))?;
    Ok(())
}

/// Read the paired signer app's Android package name. Pinned on every intent +
/// as the ContentResolver authority so a second signer app can't intercept.
pub fn get_nip55_signer_package() -> Result<Option<String>, String> {
    let conn = super::get_db_connection_guard_static()?;
    Ok(conn.query_row(
        "SELECT value FROM settings WHERE key = 'nip55_signer_package'",
        [],
        |row| row.get::<_, String>(0),
    ).ok())
}

/// Persist the paired signer app's package name. Updated on re-pair.
pub fn set_nip55_signer_package(package: &str) -> Result<(), String> {
    let conn = super::get_write_connection_guard_static()?;
    conn.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_signer_package', ?1)",
        rusqlite::params![package],
    ).map_err(|e| format!("Failed to set nip55_signer_package: {}", e))?;
    Ok(())
}

/// Atomically commit NIP-55 new-account setup: `encryption_enabled`,
/// `security_type`, `signer_type='nip55'`, and the two plaintext public fields.
/// No `pkey` is written (nothing secret exists), and any stale key material
/// from a prior local/bunker setup on this DB is scrubbed so login can't
/// mis-route through a leftover pkey/bunker row. Transactional for the same
/// reason as the sibling commits — a half-written account bricks login.
pub fn commit_nip55_account_setup(
    user_pubkey_hex: &str,
    signer_package: &str,
    encryption_enabled: bool,
    security_type: Option<&str>,
    biometric_wrap: Option<&str>,
    pin_canary: Option<&str>,
) -> Result<(), String> {
    let mut conn = super::get_write_connection_guard_static()?;
    let tx = conn.transaction()
        .map_err(|e| format!("Failed to begin tx: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', ?1)",
        rusqlite::params![if encryption_enabled { "true" } else { "false" }],
    ).map_err(|e| format!("Failed to set encryption_enabled: {}", e))?;
    if let Some(st) = security_type {
        tx.execute(
            "INSERT OR REPLACE INTO settings (key, value) VALUES ('security_type', ?1)",
            rusqlite::params![st],
        ).map_err(|e| format!("Failed to set security_type: {}", e))?;
    } else {
        tx.execute(
            "DELETE FROM settings WHERE key = 'security_type'",
            [],
        ).map_err(|e| format!("Failed to clear security_type: {}", e))?;
    }
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('signer_type', 'nip55')",
        [],
    ).map_err(|e| format!("Failed to set signer_type: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_user_pubkey', ?1)",
        rusqlite::params![user_pubkey_hex],
    ).map_err(|e| format!("Failed to set nip55_user_pubkey: {}", e))?;
    tx.execute(
        "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_signer_package', ?1)",
        rusqlite::params![signer_package],
    ).map_err(|e| format!("Failed to set nip55_signer_package: {}", e))?;
    // Scrub any secret/bunker material a prior setup on this DB may have left —
    // a NIP-55 account must never fall back to a stale key at boot.
    for stale in ["pkey", "seed", "bunker_url", "bunker_remote_pubkey"] {
        tx.execute(
            "DELETE FROM settings WHERE key = ?1",
            rusqlite::params![stale],
        ).map_err(|e| format!("Failed to clear stale {}: {}", stale, e))?;
    }
    // Biometric-only mode: the wrapped vault key is the account's SOLE
    // credential, so it must land in the SAME transaction that locks the
    // store to it — a crash between commit and a separate write would leave
    // the account permanently unrecoverable. None purges any stale wrap.
    match biometric_wrap {
        Some(w) => {
            tx.execute(
                "INSERT OR REPLACE INTO settings (key, value) VALUES ('biometric_wrapped_key', ?1)",
                rusqlite::params![w],
            ).map_err(|e| format!("Failed to set biometric wrap: {}", e))?;
        }
        None => {
            tx.execute(
                "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
                [],
            ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
        }
    }

    // The canary is a keyless account's ONLY wrong-PIN detector at boot —
    // same atomicity rule as the biometric wrap: it lands with the commit or
    // not at all. None scrubs any stale canary from a prior setup.
    match pin_canary {
        Some(c) => {
            tx.execute(
                "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_pin_check', ?1)",
                rusqlite::params![c],
            ).map_err(|e| format!("Failed to set pin canary: {}", e))?;
        }
        None => {
            tx.execute(
                "DELETE FROM settings WHERE key = 'nip55_pin_check'",
                [],
            ).map_err(|e| format!("Failed to clear pin canary: {}", e))?;
        }
    }

    tx.commit().map_err(|e| format!("Failed to commit tx: {}", e))?;
    Ok(())
}