Skip to main content

vector_core/db/
at_rest.rs

1//! Local Encryption at-rest sweep: enable, disable and rekey every encrypted
2//! column inside one transaction, so a crash leaves the store on one key.
3//!
4//! Adding an encrypted column is a two-place change: the `save_*` writer that
5//! wraps it and `migrate_community_in_tx` here. A column the sweep misses stays
6//! under the old key after a rekey and garbles on the next read.
7
8use zeroize::Zeroize;
9use crate::state::set_encryption_enabled;
10use crate::stored_event::event_kind;
11
12/// Known plaintext for the NIP-55 PIN verification canary. Encrypted under the
13/// derived key at setup (and when Local Encryption is toggled on) and
14/// re-decrypted at boot to reject a wrong PIN (a keyless account has no pkey
15/// whose failed decrypt would otherwise do this).
16pub const NIP55_PIN_CANARY: &str = "vector-nip55-pin-ok";
17
18fn encrypt_with_key(input: &str, key: &[u8; 32]) -> String {
19    crate::crypto::encrypt_with_key(input, key).expect("Encryption should not fail")
20}
21
22fn decrypt_with_key(ciphertext: &str, key: &[u8; 32]) -> Result<String, ()> {
23    crate::crypto::decrypt_with_key(ciphertext, key).map_err(|_| ())
24}
25
26/// Payload of the `encryption_migration_progress` event.
27#[derive(serde::Serialize, Clone)]
28pub struct MigrationProgress {
29    pub total: usize,
30    pub completed: usize,
31    pub phase: String,
32}
33
34// ============================================================================
35// Transactional Migrations (Crash-Safe)
36// ============================================================================
37
38/// Disable encryption inside a single SQLite transaction.
39///
40/// All mutations (event decryption, seed/pkey/PIVX decryption, flag updates)
41/// are wrapped in one transaction on the write connection. If anything fails
42/// or the app crashes before COMMIT, the database stays fully encrypted.
43/// This prevents the mixed plaintext/ciphertext state (audit C3/C4).
44///
45/// Memory-efficient: collects only event IDs upfront, processes one at a time.
46pub fn disable(
47    key: &[u8; 32],
48    progress: &dyn Fn(MigrationProgress),
49) -> Result<(), String> {
50    let mut conn = crate::db::get_write_connection_guard_static()?;
51    let tx = conn.transaction()
52        .map_err(|e| format!("Failed to begin transaction: {}", e))?;
53
54    // Set migration state inside the transaction — rolls back with everything else on crash
55    tx.execute(
56        "INSERT OR REPLACE INTO settings (key, value) VALUES ('migration_state', 'decrypting')",
57        [],
58    ).map_err(|e| format!("Failed to set migration_state: {}", e))?;
59
60    // 1. Collect encrypted event IDs (memory-efficient: just ID strings)
61    let all_ids: Vec<String> = {
62        let mut stmt = tx.prepare(
63            r#"SELECT id FROM events
64               WHERE kind IN (?1, ?2, ?3)
65               AND length(content) >= 56
66               AND content NOT GLOB '*[^0-9a-f]*'"#,
67        ).map_err(|e| format!("Failed to prepare ID query: {}", e))?;
68
69        let rows = stmt.query_map(
70            rusqlite::params![
71                event_kind::CHAT_MESSAGE as i32,
72                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
73                event_kind::MESSAGE_EDIT as i32,
74            ],
75            |row| row.get::<_, String>(0),
76        ).map_err(|e| format!("Failed to query IDs: {}", e))?;
77
78        rows.filter_map(|r| r.ok()).collect()
79    };
80
81    let total = all_ids.len();
82    let mut completed = 0;
83    let mut last_emitted_percent: i32 = -1;
84    let mut skipped = 0;
85
86    // 2. Decrypt each event within the transaction
87    for id in &all_ids {
88        let content: Option<String> = tx.query_row(
89            "SELECT content FROM events WHERE id = ?1",
90            rusqlite::params![id],
91            |row| row.get(0),
92        ).ok();
93
94        if let Some(content) = content {
95            match decrypt_with_key(&content, key) {
96                Ok(plaintext) => {
97                    tx.execute(
98                        "UPDATE events SET content = ?1 WHERE id = ?2",
99                        rusqlite::params![plaintext, id],
100                    ).map_err(|e| format!("Failed to update event: {}", e))?;
101                }
102                Err(_) => {
103                    // Content looks encrypted (hex) but isn't — skip it
104                    crate::log_info!("[Encryption] Skipping event {} - looks encrypted but isn't", id);
105                    skipped += 1;
106                }
107            }
108        }
109
110        completed += 1;
111        let current_percent = if total > 0 {
112            ((completed as f64 / total as f64) * 100.0) as i32
113        } else {
114            100
115        };
116
117        if current_percent >= last_emitted_percent + 5 {
118            last_emitted_percent = current_percent;
119            progress(MigrationProgress {
120                total,
121                completed,
122                phase: "decrypting".to_string(),
123            });
124        }
125    }
126
127    // 3. Decrypt settings and PIVX keys within the same transaction
128    progress(MigrationProgress {
129        total,
130        completed,
131        phase: "finalizing".to_string(),
132    });
133
134    decrypt_setting_in_tx(&tx, "seed", key, |v| v.contains(' '))?;
135    decrypt_setting_in_tx(&tx, "pkey", key, |v| v.starts_with("nsec"))?;
136    decrypt_setting_in_tx(&tx, "bunker_url", key, |v| v.starts_with("bunker://"))?;
137    decrypt_pivx_in_tx(&tx, key)?;
138    decrypt_community_in_tx(&tx, key)?;
139
140    // 4. Verify plaintext state within the transaction (before committing)
141    verify_plaintext_state_in_tx(&tx)?;
142
143    // 5. Update flags within the transaction
144    tx.execute(
145        "INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', 'false')",
146        [],
147    ).map_err(|e| format!("Failed to update encryption_enabled: {}", e))?;
148
149    // Plaintext account needs no wrong-PIN detector and no unlock method —
150    // drop both in-tx so no reader can infer a mode that no longer exists.
151    tx.execute(
152        "DELETE FROM settings WHERE key = 'nip55_pin_check'",
153        [],
154    ).map_err(|e| format!("Failed to clear pin canary: {}", e))?;
155    tx.execute(
156        "DELETE FROM settings WHERE key = 'security_type'",
157        [],
158    ).map_err(|e| format!("Failed to clear security_type: {}", e))?;
159
160    // The biometric wrap holds the PRE-migration derived key — drop it in the
161    // SAME transaction so no crash window can leave a stale wrap behind.
162    tx.execute(
163        "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
164        [],
165    ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
166
167    tx.execute(
168        "INSERT OR REPLACE INTO settings (key, value) VALUES ('migration_state', '')",
169        [],
170    ).map_err(|e| format!("Failed to clear migration_state: {}", e))?;
171
172    // 6. COMMIT — the atomic point. Everything succeeds or nothing does.
173    tx.commit().map_err(|e| format!("Failed to commit disable transaction: {}", e))?;
174
175    // Update cached flag now that transaction committed successfully
176    set_encryption_enabled(false);
177
178    if skipped > 0 {
179        crate::log_info!("[Encryption] Skipped {} false-positive hex events", skipped);
180    }
181    crate::log_info!("[Encryption] Disable complete: decrypted {} events", total - skipped);
182
183    Ok(())
184}
185
186/// Enable encryption inside a single SQLite transaction.
187///
188/// All mutations (event encryption, seed/pkey/PIVX encryption, flag updates)
189/// are wrapped in one transaction on the write connection. If anything fails
190/// or the app crashes before COMMIT, the database stays fully plaintext.
191/// This prevents the mixed plaintext/ciphertext state (audit C3/C4).
192///
193/// Memory-efficient: collects only event IDs upfront, processes one at a time.
194pub fn enable(
195    key: &[u8; 32],
196    security_type: &str,
197    biometric_wrap: Option<&str>,
198    nip55_canary: Option<&str>,
199    progress: &dyn Fn(MigrationProgress),
200) -> Result<(), String> {
201    let mut conn = crate::db::get_write_connection_guard_static()?;
202    let tx = conn.transaction()
203        .map_err(|e| format!("Failed to begin transaction: {}", e))?;
204
205    // Set migration state inside the transaction — rolls back with everything else on crash
206    tx.execute(
207        "INSERT OR REPLACE INTO settings (key, value) VALUES ('migration_state', 'encrypting')",
208        [],
209    ).map_err(|e| format!("Failed to set migration_state: {}", e))?;
210
211    // 1. Collect plaintext event IDs (memory-efficient: just ID strings)
212    let all_ids: Vec<String> = {
213        let mut stmt = tx.prepare(
214            r#"SELECT id FROM events
215               WHERE kind IN (?1, ?2, ?3)
216               AND (length(content) < 56 OR content GLOB '*[^0-9a-f]*')"#,
217        ).map_err(|e| format!("Failed to prepare ID query: {}", e))?;
218
219        let rows = stmt.query_map(
220            rusqlite::params![
221                event_kind::CHAT_MESSAGE as i32,
222                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
223                event_kind::MESSAGE_EDIT as i32,
224            ],
225            |row| row.get::<_, String>(0),
226        ).map_err(|e| format!("Failed to query IDs: {}", e))?;
227
228        rows.filter_map(|r| r.ok()).collect()
229    };
230
231    let total = all_ids.len();
232    let mut completed = 0;
233    let mut last_emitted_percent: i32 = -1;
234
235    // 2. Encrypt each event within the transaction
236    for id in &all_ids {
237        let content: Option<String> = tx.query_row(
238            "SELECT content FROM events WHERE id = ?1",
239            rusqlite::params![id],
240            |row| row.get(0),
241        ).ok();
242
243        if let Some(content) = content {
244            let encrypted = encrypt_with_key(&content, key);
245            tx.execute(
246                "UPDATE events SET content = ?1 WHERE id = ?2",
247                rusqlite::params![encrypted, id],
248            ).map_err(|e| format!("Failed to update event: {}", e))?;
249        }
250
251        completed += 1;
252        let current_percent = if total > 0 {
253            ((completed as f64 / total as f64) * 100.0) as i32
254        } else {
255            100
256        };
257
258        if current_percent >= last_emitted_percent + 5 {
259            last_emitted_percent = current_percent;
260            progress(MigrationProgress {
261                total,
262                completed,
263                phase: "encrypting".to_string(),
264            });
265        }
266    }
267
268    // 3. Encrypt settings and PIVX keys within the same transaction
269    progress(MigrationProgress {
270        total,
271        completed,
272        phase: "finalizing".to_string(),
273    });
274
275    encrypt_setting_in_tx(&tx, "seed", key, |v| v.contains(' '))?;
276    encrypt_setting_in_tx(&tx, "pkey", key, |v| v.starts_with("nsec"))?;
277    encrypt_setting_in_tx(&tx, "bunker_url", key, |v| v.starts_with("bunker://"))?;
278    encrypt_pivx_in_tx(&tx, key)?;
279    encrypt_community_in_tx(&tx, key)?;
280
281    // 4. Verify encrypted state within the transaction (before committing)
282    verify_encrypted_state_in_tx(&tx, key)?;
283
284    // 5. Update flags within the transaction
285    tx.execute(
286        "INSERT OR REPLACE INTO settings (key, value) VALUES ('encryption_enabled', 'true')",
287        [],
288    ).map_err(|e| format!("Failed to update encryption_enabled: {}", e))?;
289
290    tx.execute(
291        "INSERT OR REPLACE INTO settings (key, value) VALUES ('security_type', ?1)",
292        rusqlite::params![security_type],
293    ).map_err(|e| format!("Failed to update security_type: {}", e))?;
294
295    // Keyless-account canary: same atomicity rule as the wrap — the only
296    // wrong-PIN detector must land with the flags that make it load-bearing.
297    if let Some(c) = nip55_canary {
298        tx.execute(
299            "INSERT OR REPLACE INTO settings (key, value) VALUES ('nip55_pin_check', ?1)",
300            rusqlite::params![c],
301        ).map_err(|e| format!("Failed to set pin canary: {}", e))?;
302    }
303
304    // Biometric-only enable carries its wrap INTO this transaction — the row
305    // is the account's sole credential, so it must land atomically with the
306    // flags that lock the store to it. Otherwise drop any stale wrap (it
307    // holds a pre-migration key) in the same transaction.
308    match biometric_wrap {
309        Some(w) => {
310            tx.execute(
311                "INSERT OR REPLACE INTO settings (key, value) VALUES ('biometric_wrapped_key', ?1)",
312                rusqlite::params![w],
313            ).map_err(|e| format!("Failed to set biometric wrap: {}", e))?;
314        }
315        None => {
316            tx.execute(
317                "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
318                [],
319            ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
320        }
321    }
322
323    tx.execute(
324        "INSERT OR REPLACE INTO settings (key, value) VALUES ('migration_state', '')",
325        [],
326    ).map_err(|e| format!("Failed to clear migration_state: {}", e))?;
327
328    // 6. COMMIT — the atomic point. Everything succeeds or nothing does.
329    tx.commit().map_err(|e| format!("Failed to commit enable transaction: {}", e))?;
330
331    // Update cached flag now that transaction committed successfully
332    set_encryption_enabled(true);
333
334    crate::log_info!("[Encryption] Enable complete: encrypted {} events", total);
335
336    Ok(())
337}
338
339// ============================================================================
340// In-Transaction Helpers
341// ============================================================================
342
343/// Decrypt a setting value within a transaction.
344fn decrypt_setting_in_tx(
345    tx: &rusqlite::Transaction,
346    key_name: &str,
347    key: &[u8; 32],
348    is_plaintext: fn(&str) -> bool,
349) -> Result<(), String> {
350    let val: Option<String> = tx.query_row(
351        "SELECT value FROM settings WHERE key = ?1",
352        rusqlite::params![key_name],
353        |row| row.get(0),
354    ).ok();
355
356    if let Some(current) = val {
357        if is_plaintext(&current) {
358            return Ok(()); // Already plaintext, nothing to decrypt
359        }
360
361        let decrypted = decrypt_with_key(&current, key)
362            .map_err(|_| format!("Failed to decrypt setting '{}'", key_name))?;
363
364        tx.execute(
365            "UPDATE settings SET value = ?1 WHERE key = ?2",
366            rusqlite::params![decrypted, key_name],
367        ).map_err(|e| format!("Failed to update setting '{}': {}", key_name, e))?;
368    }
369
370    Ok(())
371}
372
373/// Encrypt a setting value within a transaction.
374/// Only encrypts if the value is currently plaintext.
375fn encrypt_setting_in_tx(
376    tx: &rusqlite::Transaction,
377    key_name: &str,
378    key: &[u8; 32],
379    is_plaintext: fn(&str) -> bool,
380) -> Result<(), String> {
381    let val: Option<String> = tx.query_row(
382        "SELECT value FROM settings WHERE key = ?1",
383        rusqlite::params![key_name],
384        |row| row.get(0),
385    ).ok();
386
387    if let Some(current) = val {
388        if is_plaintext(&current) {
389            let encrypted = encrypt_with_key(&current, key);
390            tx.execute(
391                "UPDATE settings SET value = ?1 WHERE key = ?2",
392                rusqlite::params![encrypted, key_name],
393            ).map_err(|e| format!("Failed to update setting '{}': {}", key_name, e))?;
394        }
395    }
396
397    Ok(())
398}
399
400/// Decrypt all PIVX promo private keys within a transaction.
401fn decrypt_pivx_in_tx(
402    tx: &rusqlite::Transaction,
403    key: &[u8; 32],
404) -> Result<(), String> {
405    let keys: Vec<(i64, String)> = {
406        let mut stmt = tx.prepare("SELECT id, privkey_encrypted FROM pivx_promos")
407            .map_err(|e| format!("Failed to prepare PIVX query: {}", e))?;
408        let rows = stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))
409            .map_err(|e| format!("Failed to query PIVX promos: {}", e))?;
410        rows.filter_map(|r| r.ok()).collect()
411    };
412
413    for (id, key_val) in keys {
414        // Only decrypt if longer than a raw key (64 chars) — meaning it's encrypted
415        if key_val.len() > 64 {
416            let decrypted = decrypt_with_key(&key_val, key)
417                .map_err(|_| format!("Failed to decrypt PIVX key {}", id))?;
418            tx.execute(
419                "UPDATE pivx_promos SET privkey_encrypted = ?1 WHERE id = ?2",
420                rusqlite::params![decrypted, id],
421            ).map_err(|e| format!("Failed to update PIVX key: {}", e))?;
422        }
423    }
424
425    Ok(())
426}
427
428/// Encrypt all PIVX promo private keys within a transaction.
429fn encrypt_pivx_in_tx(
430    tx: &rusqlite::Transaction,
431    key: &[u8; 32],
432) -> Result<(), String> {
433    let keys: Vec<(i64, String)> = {
434        let mut stmt = tx.prepare("SELECT id, privkey_encrypted FROM pivx_promos")
435            .map_err(|e| format!("Failed to prepare PIVX query: {}", e))?;
436        let rows = stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))
437            .map_err(|e| format!("Failed to query PIVX promos: {}", e))?;
438        rows.filter_map(|r| r.ok()).collect()
439    };
440
441    for (id, key_val) in keys {
442        // Raw PIVX keys are exactly 64 hex chars; encrypted output is always longer
443        if key_val.len() <= 64 {
444            let encrypted = encrypt_with_key(&key_val, key);
445            tx.execute(
446                "UPDATE pivx_promos SET privkey_encrypted = ?1 WHERE id = ?2",
447                rusqlite::params![encrypted, id],
448            ).map_err(|e| format!("Failed to update PIVX key: {}", e))?;
449        }
450    }
451
452    Ok(())
453}
454
455// ============================================================================
456// Community (Concord) at-rest migration
457// ============================================================================
458//
459// The community tables hold their own secrets + identifying metadata, wrapped the same way as the
460// pkey/event-content/PIVX fields. These helpers re-wrap them in lockstep with the three lifecycle
461// flows. One direction-agnostic engine: `dec` decrypts with the given key first, then `enc` encrypts
462// with the given key — so enable = enc only, disable = dec only, rekey = dec(old) then enc(new). The
463// discriminators (text `looks_encrypted`; blob 32-byte-raw vs longer) make every step idempotent, so
464// a re-run or the one-time backfill never double-wraps or fails on already-migrated rows.
465
466// Idempotency discriminator for both directions is the AEAD tag itself — "already encrypted under
467// key `k`" iff the value decrypts cleanly under `k` — NOT a hex/length content heuristic. A content
468// heuristic (`looks_encrypted`) silently mis-classifies plaintext that happens to be bare hex (invite
469// tokens, creator pubkeys) as ciphertext and skips wrapping it; the tag check can't be fooled (a
470// 16-byte Poly1305 tag won't validate on non-ciphertext), and it never double-wraps on re-run.
471
472fn xform_text(v: &str, enc: Option<&[u8; 32]>, dec: Option<&[u8; 32]>) -> Result<String, String> {
473    let mut s = v.to_string();
474    if let Some(k) = dec {
475        // Try to decrypt; anything that isn't ciphertext under `k` fails the tag and is kept as-is.
476        if let Ok(p) = decrypt_with_key(&s, k) {
477            s = p;
478        }
479    }
480    if let Some(k) = enc {
481        // Encrypt unless it already decrypts under `k` (already wrapped → leave it).
482        if decrypt_with_key(&s, k).is_err() {
483            s = encrypt_with_key(&s, k);
484        }
485    }
486    Ok(s)
487}
488
489fn xform_text_opt(v: &Option<String>, enc: Option<&[u8; 32]>, dec: Option<&[u8; 32]>) -> Result<Option<String>, String> {
490    v.as_deref().map(|s| xform_text(s, enc, dec)).transpose()
491}
492
493fn xform_blob(v: &[u8], enc: Option<&[u8; 32]>, dec: Option<&[u8; 32]>) -> Result<Vec<u8>, String> {
494    let mut b = v.to_vec();
495    if let Some(k) = dec {
496        // A raw 32-byte key (or anything not ciphertext under `k`) fails the tag and is kept as-is.
497        if let Ok(p) = crate::crypto::decrypt_blob_with_key(&b, k) {
498            b = p;
499        }
500    }
501    if let Some(k) = enc {
502        if crate::crypto::decrypt_blob_with_key(&b, k).is_err() {
503            b = crate::crypto::encrypt_blob_with_key(&b, k)?;
504        }
505    }
506    Ok(b)
507}
508
509#[cfg(test)]
510mod xform_tests {
511    use super::*;
512    const K: [u8; 32] = [7u8; 32];
513    const K2: [u8; 32] = [9u8; 32];
514
515    #[test]
516    fn bare_hex_plaintext_is_encrypted_not_skipped() {
517        // C1 regression: a 64-char-hex plaintext (invite token / creator pubkey) must be wrapped,
518        // never mistaken for ciphertext.
519        let token = "de".repeat(32); // 64 lowercase hex chars
520        let wrapped = xform_text(&token, Some(&K), None).unwrap();
521        assert_ne!(wrapped, token, "bare-hex plaintext must be encrypted");
522        assert_eq!(decrypt_with_key(&wrapped, &K).unwrap(), token);
523    }
524
525    #[test]
526    fn encrypt_is_idempotent() {
527        let token = "de".repeat(32);
528        let once = xform_text(&token, Some(&K), None).unwrap();
529        let twice = xform_text(&once, Some(&K), None).unwrap();
530        assert_eq!(once, twice, "re-running encrypt must not double-wrap");
531        assert_eq!(decrypt_with_key(&twice, &K).unwrap(), token);
532    }
533
534    #[test]
535    fn disable_then_reenable_roundtrips_bare_hex() {
536        let token = "ab".repeat(32);
537        let enc = xform_text(&token, Some(&K), None).unwrap();
538        let dec = xform_text(&enc, None, Some(&K)).unwrap();
539        assert_eq!(dec, token, "disable decrypts back to plaintext");
540        let re = xform_text(&dec, Some(&K), None).unwrap();
541        assert_eq!(decrypt_with_key(&re, &K).unwrap(), token, "re-enable wraps the bare-hex again");
542    }
543
544    #[test]
545    fn rekey_rewraps_bare_hex_under_new_key() {
546        let token = "cd".repeat(32);
547        let old = xform_text(&token, Some(&K), None).unwrap();
548        let new = xform_text(&old, Some(&K2), Some(&K)).unwrap(); // dec old, enc new
549        assert!(decrypt_with_key(&new, &K).is_err(), "no longer under old key");
550        assert_eq!(decrypt_with_key(&new, &K2).unwrap(), token, "now under new key");
551    }
552
553    #[test]
554    fn nonhex_plaintext_roundtrips() {
555        for v in ["general", "{\"roles\":[]}", "wss://relay.example", ""] {
556            let enc = xform_text(v, Some(&K), None).unwrap();
557            assert_eq!(decrypt_with_key(&enc, &K).unwrap(), v);
558            assert_eq!(xform_text(&enc, None, Some(&K)).unwrap(), v);
559        }
560    }
561
562    /// The Concord tables exactly as the sweep addresses them (columns it does
563    /// not touch omitted).
564    fn migrate_test_schema(conn: &rusqlite::Connection) {
565        conn.execute_batch(
566            "CREATE TABLE communities (community_id TEXT, server_root_key BLOB, name TEXT, relays TEXT, description TEXT, icon TEXT, banner TEXT, banlist TEXT, owner_attestation TEXT, roles TEXT, invite_registry TEXT, owner_pubkey TEXT, owner_salt TEXT, meta_extra TEXT, control_pk TEXT, control_root BLOB);
567             CREATE TABLE community_channels (channel_id TEXT, channel_key BLOB, name TEXT);
568             CREATE TABLE community_epoch_keys (key BLOB);
569             CREATE TABLE community_message_keys (outer_event_id TEXT, ephemeral_secret BLOB, relays TEXT);
570             CREATE TABLE pending_community_invites (community_id TEXT, bundle_json TEXT, inviter_npub TEXT);
571             CREATE TABLE community_public_invites (token TEXT, url TEXT, label TEXT);
572             CREATE TABLE community_invite_link_sets (creator TEXT, locators TEXT);
573             CREATE TABLE community_guestbook (community_id TEXT, events TEXT, cursor_secs INTEGER);
574             CREATE TABLE community_policies (community_id TEXT, policy_id TEXT, bytes TEXT, hash TEXT, enabled INTEGER, updated_at INTEGER);",
575        ).unwrap();
576    }
577
578    /// Every encrypted store must survive a rekey. The guestbook was missed
579    /// when it landed: a PIN change left it under the old key, the read
580    /// swallowed the failure as an empty list, and every member count collapsed
581    /// until a full plane walk rebuilt it.
582    #[test]
583    fn guestbook_and_policies_survive_enable_rekey_disable() {
584        let mut conn = rusqlite::Connection::open_in_memory().unwrap();
585        migrate_test_schema(&conn);
586        conn.execute(
587            "INSERT INTO community_guestbook (community_id, events, cursor_secs) VALUES (?1, ?2, 7)",
588            rusqlite::params!["aa".repeat(32), r#"[{"rumor_id":"x"}]"#],
589        )
590        .unwrap();
591        conn.execute(
592            "INSERT INTO community_policies (community_id, policy_id, bytes, hash, enabled, updated_at)
593             VALUES (?1, 'p1', ?2, 'hh', 1, 0)",
594            rusqlite::params!["aa".repeat(32), r#"{"format":1,"name":"x"}"#],
595        )
596        .unwrap();
597        let read = |c: &rusqlite::Connection| -> (String, String) {
598            (
599                c.query_row("SELECT events FROM community_guestbook", [], |r| r.get(0)).unwrap(),
600                c.query_row("SELECT bytes FROM community_policies", [], |r| r.get(0)).unwrap(),
601            )
602        };
603        let before = read(&conn);
604        let k1 = [0x21u8; 32];
605        let k2 = [0x22u8; 32];
606        let step = |c: &mut rusqlite::Connection, enc: Option<&[u8; 32]>, dec: Option<&[u8; 32]>| {
607            let tx = c.transaction().unwrap();
608            migrate_community_in_tx(&tx, enc, dec).unwrap();
609            tx.commit().unwrap();
610        };
611        step(&mut conn, Some(&k1), None);
612        // The teeth: a table the sweep never touches stays PLAINTEXT, and a
613        // round-trip over it would pass trivially. Assert the enable actually
614        // wrapped both stores before checking they come back.
615        let enabled = read(&conn);
616        assert_ne!(enabled.0, before.0, "the guestbook must actually be encrypted by the sweep");
617        assert_ne!(enabled.1, before.1, "the policy bytes must actually be encrypted by the sweep");
618
619        step(&mut conn, Some(&k2), Some(&k1));
620        step(&mut conn, None, Some(&k2));
621        assert_eq!(read(&conn), before, "a rekey round-trip must return the plaintext it started with");
622    }
623
624    /// Sweep parity for the v2 columns: the owner commitment and the CORD-02 §2
625    /// control pair must survive enable → rekey → disable. A missed column stays
626    /// under the old key — the pair silently drops to read-only on load, and a
627    /// garbled owner commitment fails the whole v2 load.
628    #[test]
629    fn v2_control_pair_survives_enable_rekey_disable() {
630        let mut conn = rusqlite::Connection::open_in_memory().unwrap();
631        migrate_test_schema(&conn);
632        let control_root: Vec<u8> = vec![0x5Au8; 32];
633        conn.execute(
634            "INSERT INTO communities (community_id, server_root_key, name, relays, banlist, roles, invite_registry, owner_pubkey, owner_salt, meta_extra, control_pk, control_root)
635             VALUES (?1, ?2, 'n', '[]', '[]', '{}', '[]', ?3, ?4, '{\"custom\":null,\"extra\":{}}', ?5, ?6)",
636            rusqlite::params!["cc".repeat(32), vec![0x11u8; 32], "ab".repeat(32), "cd".repeat(32), "ef".repeat(32), control_root],
637        ).unwrap();
638        let read = |c: &rusqlite::Connection| -> (String, String, Vec<u8>) {
639            c.query_row("SELECT owner_pubkey, control_pk, control_root FROM communities", [], |r| {
640                Ok((r.get(0)?, r.get(1)?, r.get(2)?))
641            }).unwrap()
642        };
643
644        let tx = conn.transaction().unwrap();
645        encrypt_community_in_tx(&tx, &K).unwrap();
646        tx.commit().unwrap();
647        let (owner_pk, control_pk, root) = read(&conn);
648        assert_ne!(owner_pk, "ab".repeat(32), "owner commitment must be wrapped after enable");
649        assert_ne!(control_pk, "ef".repeat(32), "control_pk must be wrapped after enable");
650        assert_ne!(root, vec![0x5Au8; 32], "control_root must be wrapped after enable");
651
652        let tx = conn.transaction().unwrap();
653        rekey_community_in_tx(&tx, &K, &K2).unwrap();
654        tx.commit().unwrap();
655
656        let tx = conn.transaction().unwrap();
657        decrypt_community_in_tx(&tx, &K2).unwrap();
658        tx.commit().unwrap();
659        let (owner_pk, control_pk, root) = read(&conn);
660        assert_eq!(owner_pk, "ab".repeat(32), "owner commitment survives enable → rekey → disable");
661        assert_eq!(control_pk, "ef".repeat(32), "control_pk survives enable → rekey → disable");
662        assert_eq!(root, vec![0x5Au8; 32], "control_root survives enable → rekey → disable");
663    }
664
665    /// Sweep parity: every encrypted community_public_invites column must survive
666    /// enable → rekey → disable (a column missed by the sweep garbles on the first rekey).
667    #[test]
668    fn public_invite_label_survives_enable_rekey_disable() {
669        let mut conn = rusqlite::Connection::open_in_memory().unwrap();
670        migrate_test_schema(&conn);
671        conn.execute(
672            "INSERT INTO community_public_invites (token, url, label) VALUES (?1, ?2, ?3)",
673            rusqlite::params!["ab".repeat(32), "https://vectorapp.io/invite#x", "Reddit"],
674        ).unwrap();
675        let read_label = |c: &rusqlite::Connection| -> String {
676            c.query_row("SELECT label FROM community_public_invites", [], |r| r.get(0)).unwrap()
677        };
678
679        let tx = conn.transaction().unwrap();
680        encrypt_community_in_tx(&tx, &K).unwrap();
681        tx.commit().unwrap();
682        assert_ne!(read_label(&conn), "Reddit", "label must be wrapped after enable");
683
684        let tx = conn.transaction().unwrap();
685        rekey_community_in_tx(&tx, &K, &K2).unwrap();
686        tx.commit().unwrap();
687
688        let tx = conn.transaction().unwrap();
689        decrypt_community_in_tx(&tx, &K2).unwrap();
690        tx.commit().unwrap();
691        assert_eq!(read_label(&conn), "Reddit", "label must survive enable → rekey → disable");
692    }
693
694    #[test]
695    fn blob_roundtrip_and_idempotent() {
696        let raw = [0x42u8; 32];
697        let enc = xform_blob(&raw, Some(&K), None).unwrap();
698        assert_eq!(enc.len(), 60);
699        assert_eq!(xform_blob(&enc, Some(&K), None).unwrap(), enc, "blob encrypt idempotent");
700        assert_eq!(xform_blob(&enc, None, Some(&K)).unwrap(), raw.to_vec());
701        // rekey path for blobs
702        let re = xform_blob(&enc, Some(&K2), Some(&K)).unwrap();
703        assert_eq!(crate::crypto::decrypt_blob_with_key(&re, &K2).unwrap(), raw.to_vec());
704    }
705}
706
707fn migrate_community_in_tx(
708    tx: &rusqlite::Transaction,
709    enc: Option<&[u8; 32]>,
710    dec: Option<&[u8; 32]>,
711) -> Result<(), String> {
712    // communities: 2 secret BLOBs + identifying text (some nullable). Every
713    // encrypted column save_community / save_community_v2 writes MUST appear
714    // here, or it stays under the old key after a rekey and garbles on read.
715    #[allow(clippy::type_complexity)]
716    let rows: Vec<(String, Vec<u8>, String, String, Option<String>, Option<String>, Option<String>, String, Option<String>, String, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<Vec<u8>>)> = {
717        let mut stmt = tx.prepare(
718            "SELECT community_id, server_root_key, name, relays, description, icon, banner, banlist, owner_attestation, roles, invite_registry, owner_pubkey, owner_salt, meta_extra, control_pk, control_root FROM communities",
719        ).map_err(|e| format!("prepare communities: {e}"))?;
720        let mapped = stmt.query_map([], |r| Ok((
721            r.get::<_, String>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, String>(2)?, r.get::<_, String>(3)?,
722            r.get::<_, Option<String>>(4)?, r.get::<_, Option<String>>(5)?, r.get::<_, Option<String>>(6)?,
723            r.get::<_, String>(7)?, r.get::<_, Option<String>>(8)?, r.get::<_, String>(9)?, r.get::<_, String>(10)?,
724            r.get::<_, Option<String>>(11)?, r.get::<_, Option<String>>(12)?, r.get::<_, Option<String>>(13)?,
725            r.get::<_, Option<String>>(14)?, r.get::<_, Option<Vec<u8>>>(15)?,
726        ))).map_err(|e| format!("query communities: {e}"))?;
727        mapped.filter_map(|r| r.ok()).collect()
728    };
729    for (id, root, name, relays, desc, icon, banner, banlist, owner, roles, registry, owner_pk, owner_salt, meta_extra, control_pk, control_root) in rows {
730        tx.execute(
731            "UPDATE communities SET server_root_key=?1, name=?2, relays=?3, description=?4, icon=?5,
732                banner=?6, banlist=?7, owner_attestation=?8, roles=?9, invite_registry=?10,
733                owner_pubkey=?11, owner_salt=?12, meta_extra=?13, control_pk=?14, control_root=?15 WHERE community_id=?16",
734            rusqlite::params![
735                xform_blob(&root, enc, dec)?, xform_text(&name, enc, dec)?, xform_text(&relays, enc, dec)?,
736                xform_text_opt(&desc, enc, dec)?, xform_text_opt(&icon, enc, dec)?, xform_text_opt(&banner, enc, dec)?,
737                xform_text(&banlist, enc, dec)?, xform_text_opt(&owner, enc, dec)?, xform_text(&roles, enc, dec)?,
738                xform_text(&registry, enc, dec)?, xform_text_opt(&owner_pk, enc, dec)?, xform_text_opt(&owner_salt, enc, dec)?,
739                xform_text_opt(&meta_extra, enc, dec)?, xform_text_opt(&control_pk, enc, dec)?,
740                control_root.as_deref().map(|b| xform_blob(b, enc, dec)).transpose()?, id,
741            ],
742        ).map_err(|e| format!("update communities: {e}"))?;
743    }
744
745    // community_channels: channel_key BLOB + name.
746    let chans: Vec<(String, Vec<u8>, String)> = {
747        let mut stmt = tx.prepare("SELECT channel_id, channel_key, name FROM community_channels")
748            .map_err(|e| format!("prepare channels: {e}"))?;
749        let mapped = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, String>(2)?)))
750            .map_err(|e| format!("query channels: {e}"))?;
751        mapped.filter_map(|r| r.ok()).collect()
752    };
753    for (cid, key, name) in chans {
754        tx.execute(
755            "UPDATE community_channels SET channel_key=?1, name=?2 WHERE channel_id=?3",
756            rusqlite::params![xform_blob(&key, enc, dec)?, xform_text(&name, enc, dec)?, cid],
757        ).map_err(|e| format!("update channel: {e}"))?;
758    }
759
760    // community_epoch_keys: key BLOB (rowid-keyed — coordinate columns aren't unique enough alone).
761    let eks: Vec<(i64, Vec<u8>)> = {
762        let mut stmt = tx.prepare("SELECT rowid, key FROM community_epoch_keys")
763            .map_err(|e| format!("prepare epoch keys: {e}"))?;
764        let mapped = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
765            .map_err(|e| format!("query epoch keys: {e}"))?;
766        mapped.filter_map(|r| r.ok()).collect()
767    };
768    for (rowid, key) in eks {
769        tx.execute("UPDATE community_epoch_keys SET key=?1 WHERE rowid=?2",
770            rusqlite::params![xform_blob(&key, enc, dec)?, rowid])
771            .map_err(|e| format!("update epoch key: {e}"))?;
772    }
773
774    // community_message_keys: ephemeral_secret BLOB + relays.
775    let mks: Vec<(String, Vec<u8>, String)> = {
776        let mut stmt = tx.prepare("SELECT outer_event_id, ephemeral_secret, relays FROM community_message_keys")
777            .map_err(|e| format!("prepare msg keys: {e}"))?;
778        let mapped = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, String>(2)?)))
779            .map_err(|e| format!("query msg keys: {e}"))?;
780        mapped.filter_map(|r| r.ok()).collect()
781    };
782    for (oid, secret, relays) in mks {
783        tx.execute("UPDATE community_message_keys SET ephemeral_secret=?1, relays=?2 WHERE outer_event_id=?3",
784            rusqlite::params![xform_blob(&secret, enc, dec)?, xform_text(&relays, enc, dec)?, oid])
785            .map_err(|e| format!("update msg key: {e}"))?;
786    }
787
788    // pending_community_invites: bundle_json + inviter_npub.
789    let pend: Vec<(String, String, String)> = {
790        let mut stmt = tx.prepare("SELECT community_id, bundle_json, inviter_npub FROM pending_community_invites")
791            .map_err(|e| format!("prepare pending: {e}"))?;
792        let mapped = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
793            .map_err(|e| format!("query pending: {e}"))?;
794        mapped.filter_map(|r| r.ok()).collect()
795    };
796    for (id, bundle, inviter) in pend {
797        tx.execute("UPDATE pending_community_invites SET bundle_json=?1, inviter_npub=?2 WHERE community_id=?3",
798            rusqlite::params![xform_text(&bundle, enc, dec)?, xform_text(&inviter, enc, dec)?, id])
799            .map_err(|e| format!("update pending: {e}"))?;
800    }
801
802    // community_guestbook: the folded Join/Leave/Kick/Snapshot events. Missed
803    // since the store landed — a rekey left them under the old key, the read
804    // silently `unwrap_or_default()`s to empty, and every member count collapses
805    // until the next full plane walk rebuilds it.
806    let gbs: Vec<(String, String)> = {
807        let mut stmt = tx.prepare("SELECT community_id, events FROM community_guestbook")
808            .map_err(|e| format!("prepare guestbook: {e}"))?;
809        let mapped = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
810            .map_err(|e| format!("query guestbook: {e}"))?;
811        mapped.filter_map(|r| r.ok()).collect()
812    };
813    for (id, events) in gbs {
814        tx.execute("UPDATE community_guestbook SET events=?1 WHERE community_id=?2",
815            rusqlite::params![xform_text(&events, enc, dec)?, id])
816            .map_err(|e| format!("update guestbook: {e}"))?;
817    }
818
819    // community_policies: the exact policy bytes a community declared.
820    let pols: Vec<(String, String, String)> = {
821        let mut stmt = tx.prepare("SELECT community_id, policy_id, bytes FROM community_policies")
822            .map_err(|e| format!("prepare policies: {e}"))?;
823        let mapped = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
824            .map_err(|e| format!("query policies: {e}"))?;
825        mapped.filter_map(|r| r.ok()).collect()
826    };
827    for (cid, pid, bytes) in pols {
828        tx.execute("UPDATE community_policies SET bytes=?1 WHERE community_id=?2 AND policy_id=?3",
829            rusqlite::params![xform_text(&bytes, enc, dec)?, cid, pid])
830            .map_err(|e| format!("update policies: {e}"))?;
831    }
832
833    // community_public_invites: token + url + label (rowid-keyed — token is itself wrapped).
834    let pubs: Vec<(i64, String, String, Option<String>)> = {
835        let mut stmt = tx.prepare("SELECT rowid, token, url, label FROM community_public_invites")
836            .map_err(|e| format!("prepare public invites: {e}"))?;
837        let mapped = stmt.query_map([], |r| Ok((
838            r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, Option<String>>(3)?,
839        ))).map_err(|e| format!("query public invites: {e}"))?;
840        mapped.filter_map(|r| r.ok()).collect()
841    };
842    for (rowid, token, url, label) in pubs {
843        tx.execute("UPDATE community_public_invites SET token=?1, url=?2, label=?3 WHERE rowid=?4",
844            rusqlite::params![xform_text(&token, enc, dec)?, xform_text(&url, enc, dec)?, xform_text_opt(&label, enc, dec)?, rowid])
845            .map_err(|e| format!("update public invite: {e}"))?;
846    }
847
848    // community_invite_link_sets: creator + locators (rowid-keyed — creator is itself wrapped).
849    let sets: Vec<(i64, String, String)> = {
850        let mut stmt = tx.prepare("SELECT rowid, creator, locators FROM community_invite_link_sets")
851            .map_err(|e| format!("prepare link sets: {e}"))?;
852        let mapped = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))
853            .map_err(|e| format!("query link sets: {e}"))?;
854        mapped.filter_map(|r| r.ok()).collect()
855    };
856    for (rowid, creator, locators) in sets {
857        tx.execute("UPDATE community_invite_link_sets SET creator=?1, locators=?2 WHERE rowid=?3",
858            rusqlite::params![xform_text(&creator, enc, dec)?, xform_text(&locators, enc, dec)?, rowid])
859            .map_err(|e| format!("update link set: {e}"))?;
860    }
861
862    Ok(())
863}
864
865/// Encrypt all Concord secrets + metadata (enable flow).
866fn encrypt_community_in_tx(tx: &rusqlite::Transaction, key: &[u8; 32]) -> Result<(), String> {
867    migrate_community_in_tx(tx, Some(key), None)
868}
869/// Decrypt all Concord secrets + metadata (disable flow).
870fn decrypt_community_in_tx(tx: &rusqlite::Transaction, key: &[u8; 32]) -> Result<(), String> {
871    migrate_community_in_tx(tx, None, Some(key))
872}
873/// Re-wrap all Concord secrets + metadata old key → new key (PIN-rekey flow).
874fn rekey_community_in_tx(tx: &rusqlite::Transaction, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<(), String> {
875    migrate_community_in_tx(tx, Some(new_key), Some(old_key))
876}
877
878/// One-time backfill for an account that already had Local Encryption ON *before* Concord at-rest
879/// encryption shipped: its community rows are still plaintext. Wrap them once, gated by a per-account
880/// settings flag. Idempotent (the field discriminators skip already-wrapped rows), so a crash mid-pass
881/// re-runs next login. No-op when encryption is off, the key vault is empty, or the flag is set.
882/// Best-effort at the call site — a failure leaves the flag unset and retries, never blocks login.
883pub fn backfill_community_at_rest() -> Result<(), String> {
884    if !crate::state::is_encryption_enabled_fast() {
885        return Ok(());
886    }
887    if crate::db::get_sql_setting("community_at_rest_encrypted".to_string())
888        .ok()
889        .flatten()
890        .as_deref()
891        == Some("1")
892    {
893        return Ok(());
894    }
895    let mut key = match crate::state::ENCRYPTION_KEY.get() {
896        Some(k) => k,
897        None => return Ok(()), // locked / not yet derived — retry on a later login
898    };
899    let conn = crate::db::get_write_connection_guard_static()?;
900    let tx = conn.unchecked_transaction().map_err(|e| format!("backfill tx: {e}"))?;
901    let res = encrypt_community_in_tx(&tx, &key);
902    key.zeroize();
903    res?;
904    tx.execute(
905        "INSERT OR REPLACE INTO settings (key, value) VALUES ('community_at_rest_encrypted', '1')",
906        [],
907    )
908    .map_err(|e| format!("backfill flag: {e}"))?;
909    tx.commit().map_err(|e| format!("backfill commit: {e}"))?;
910    crate::log_info!("[Encryption] Community at-rest backfill complete");
911    Ok(())
912}
913
914// ============================================================================
915// Post-Migration Verification (In-Transaction)
916// ============================================================================
917
918/// Verify all critical data is plaintext within a transaction.
919/// Catches any bugs (double-encryption, missed fields) BEFORE committing.
920fn verify_plaintext_state_in_tx(tx: &rusqlite::Transaction) -> Result<(), String> {
921    // Verify pkey is plaintext (starts with "nsec")
922    if let Ok(pkey) = tx.query_row::<String, _, _>(
923        "SELECT value FROM settings WHERE key = 'pkey'", [], |row| row.get(0),
924    ) {
925        if !pkey.starts_with("nsec") {
926            return Err(format!(
927                "VERIFICATION FAILED: pkey is not plaintext after decryption (len={}, prefix={}). \
928                 Aborting to protect your data — encryption status was NOT changed.",
929                pkey.len(), &pkey[..pkey.len().min(8)]
930            ));
931        }
932    }
933
934    // Verify seed is plaintext (contains spaces = BIP39 mnemonic)
935    if let Ok(seed) = tx.query_row::<String, _, _>(
936        "SELECT value FROM settings WHERE key = 'seed'", [], |row| row.get(0),
937    ) {
938        if !seed.contains(' ') {
939            return Err(
940                "VERIFICATION FAILED: seed is not plaintext after decryption. \
941                 Aborting to protect your data — encryption status was NOT changed."
942                    .to_string(),
943            );
944        }
945    }
946
947    // Verify PIVX keys are raw (exactly 64 hex chars)
948    let mut stmt = tx.prepare("SELECT id, length(privkey_encrypted) FROM pivx_promos")
949        .map_err(|e| format!("Verification query failed: {}", e))?;
950    let bad_keys: Vec<i64> = stmt
951        .query_map([], |row| {
952            let id: i64 = row.get(0)?;
953            let len: i64 = row.get(1)?;
954            Ok((id, len))
955        })
956        .map_err(|e| format!("Verification query failed: {}", e))?
957        .filter_map(|r| r.ok())
958        .filter(|(_, len)| *len > 64)
959        .map(|(id, _)| id)
960        .collect();
961
962    if !bad_keys.is_empty() {
963        return Err(format!(
964            "VERIFICATION FAILED: {} PIVX key(s) still encrypted (IDs: {:?}). \
965             Aborting to protect your data — encryption status was NOT changed.",
966            bad_keys.len(), bad_keys
967        ));
968    }
969
970    crate::log_info!("[Encryption] Verification passed: all data confirmed plaintext");
971    Ok(())
972}
973
974/// Verify all critical data is encrypted within a transaction.
975/// Decrypts each value to confirm it round-trips back to valid plaintext.
976fn verify_encrypted_state_in_tx(
977    tx: &rusqlite::Transaction,
978    key: &[u8; 32],
979) -> Result<(), String> {
980    // Verify pkey is encrypted and round-trips to nsec
981    if let Ok(pkey) = tx.query_row::<String, _, _>(
982        "SELECT value FROM settings WHERE key = 'pkey'", [], |row| row.get(0),
983    ) {
984        if pkey.starts_with("nsec") {
985            return Err(
986                "VERIFICATION FAILED: pkey is still plaintext after encryption. \
987                 Aborting to protect your data — encryption status was NOT changed."
988                    .to_string(),
989            );
990        }
991        match decrypt_with_key(&pkey, key) {
992            Ok(decrypted) if decrypted.starts_with("nsec") => {}
993            Ok(decrypted) => {
994                return Err(format!(
995                    "VERIFICATION FAILED: pkey decrypts but not to nsec (len={}, prefix={}). \
996                     Possible double-encryption. Aborting.",
997                    decrypted.len(), &decrypted[..decrypted.len().min(6)]
998                ));
999            }
1000            Err(_) => {
1001                return Err(
1002                    "VERIFICATION FAILED: pkey cannot be decrypted with current key. Aborting."
1003                        .to_string(),
1004                );
1005            }
1006        }
1007    }
1008
1009    // Verify seed is encrypted and round-trips to BIP39
1010    if let Ok(seed) = tx.query_row::<String, _, _>(
1011        "SELECT value FROM settings WHERE key = 'seed'", [], |row| row.get(0),
1012    ) {
1013        if seed.contains(' ') {
1014            return Err(
1015                "VERIFICATION FAILED: seed is still plaintext after encryption. Aborting."
1016                    .to_string(),
1017            );
1018        }
1019        match decrypt_with_key(&seed, key) {
1020            Ok(decrypted) if decrypted.contains(' ') => {}
1021            Ok(_) => {
1022                return Err(
1023                    "VERIFICATION FAILED: seed decrypts but not to BIP39 mnemonic. \
1024                     Possible double-encryption. Aborting."
1025                        .to_string(),
1026                );
1027            }
1028            Err(_) => {
1029                return Err(
1030                    "VERIFICATION FAILED: seed cannot be decrypted with current key. Aborting."
1031                        .to_string(),
1032                );
1033            }
1034        }
1035    }
1036
1037    crate::log_info!("[Encryption] Verification passed: all data confirmed encrypted and round-trips correctly");
1038    Ok(())
1039}
1040
1041/// Does `key` provably decrypt this account's material? Checks whichever
1042/// anchor the account shape has: the pkey ciphertext (local/bunker) or the
1043/// canary (keyless NIP-55). No anchor (plaintext pkey, no canary) = nothing to
1044/// contradict, so accept — matching every other credential check in the app.
1045pub fn key_matches_account(key: &[u8; 32]) -> bool {
1046    let Ok(conn) = crate::db::get_db_connection_guard_static() else {
1047        return false;
1048    };
1049    let pkey: Option<String> = conn
1050        .query_row("SELECT value FROM settings WHERE key = 'pkey'", [], |r| r.get(0))
1051        .ok();
1052    if let Some(p) = pkey {
1053        if !p.starts_with("nsec") {
1054            return matches!(decrypt_with_key(&p, key), Ok(d) if d.starts_with("nsec"));
1055        }
1056    }
1057    let canary: Option<String> = conn
1058        .query_row("SELECT value FROM settings WHERE key = 'nip55_pin_check'", [], |r| r.get(0))
1059        .ok();
1060    if let Some(c) = canary {
1061        return matches!(
1062            decrypt_with_key(&c, key),
1063            Ok(d) if d == NIP55_PIN_CANARY
1064        );
1065    }
1066    true
1067}
1068
1069/// Perform the entire re-key operation inside a single SQLite transaction.
1070///
1071/// If ANY step fails (or the app crashes), the transaction auto-rolls back
1072/// and the database remains entirely on the old key. This prevents the
1073/// catastrophic "split-key" state where some data uses the old key and
1074/// some uses the new key (audit C1).
1075///
1076/// Memory-efficient: collects only event IDs upfront (small strings),
1077/// then processes one event at a time within the transaction.
1078pub fn rekey(
1079    old_key: &[u8; 32],
1080    new_key: &[u8; 32],
1081    security_type: &str,
1082    biometric_wrap: Option<&str>,
1083    progress: &dyn Fn(MigrationProgress),
1084) -> Result<(), String> {
1085    let mut conn = crate::db::get_write_connection_guard_static()?;
1086
1087    // Begin transaction — auto-rolls back on drop if not committed
1088    let tx = conn.transaction()
1089        .map_err(|e| format!("Failed to begin transaction: {}", e))?;
1090
1091    // Set migration state inside the transaction — rolls back with everything else on crash
1092    tx.execute(
1093        "INSERT OR REPLACE INTO settings (key, value) VALUES ('migration_state', 'rekeying')",
1094        [],
1095    ).map_err(|e| format!("Failed to set migration_state: {}", e))?;
1096
1097    // 1. Collect all encrypted event IDs (memory-efficient: just ID strings)
1098    let all_ids: Vec<String> = {
1099        let mut stmt = tx.prepare(
1100            r#"SELECT id FROM events
1101               WHERE kind IN (?1, ?2, ?3)
1102               AND length(content) >= 56
1103               AND content NOT GLOB '*[^0-9a-f]*'"#,
1104        ).map_err(|e| format!("Failed to prepare ID query: {}", e))?;
1105
1106        let rows = stmt.query_map(
1107            rusqlite::params![
1108                event_kind::CHAT_MESSAGE as i32,
1109                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1110                event_kind::MESSAGE_EDIT as i32,
1111            ],
1112            |row| row.get::<_, String>(0),
1113        ).map_err(|e| format!("Failed to query IDs: {}", e))?;
1114
1115        rows.filter_map(|r| r.ok()).collect()
1116    };
1117
1118    let total = all_ids.len();
1119    let mut completed = 0;
1120    let mut last_emitted_percent: i32 = -1;
1121
1122    // 2. Re-key each event (one at a time, memory-efficient)
1123    for id in &all_ids {
1124        let content: Option<String> = tx.query_row(
1125            "SELECT content FROM events WHERE id = ?1",
1126            rusqlite::params![id],
1127            |row| row.get(0),
1128        ).ok();
1129
1130        if let Some(content) = content {
1131            match decrypt_with_key(&content, old_key) {
1132                Ok(plaintext) => {
1133                    let re_encrypted = encrypt_with_key(&plaintext, new_key);
1134                    tx.execute(
1135                        "UPDATE events SET content = ?1 WHERE id = ?2",
1136                        rusqlite::params![re_encrypted, id],
1137                    ).map_err(|e| format!("Failed to update event: {}", e))?;
1138                }
1139                Err(_) => {
1140                    // Content looks encrypted but can't be decrypted — skip
1141                    crate::log_warn!("[Rekey] Skipping event {} - decrypt failed", id);
1142                }
1143            }
1144        }
1145
1146        completed += 1;
1147        let current_percent = if total > 0 {
1148            ((completed as f64 / total as f64) * 100.0) as i32
1149        } else {
1150            100
1151        };
1152
1153        if current_percent >= last_emitted_percent + 5 {
1154            last_emitted_percent = current_percent;
1155            progress(MigrationProgress {
1156                total,
1157                completed,
1158                phase: "rekeying".to_string(),
1159            });
1160        }
1161    }
1162
1163    // 3. Re-key settings within the same transaction
1164    progress(MigrationProgress {
1165        total,
1166        completed,
1167        phase: "finalizing".to_string(),
1168    });
1169
1170    rekey_setting_in_tx(&tx, "pkey", old_key, new_key)?;
1171    rekey_setting_in_tx(&tx, "seed", old_key, new_key)?;
1172    // A keyless (NIP-55) account has no pkey, so the canary is its ONLY
1173    // wrong-credential detector; a bunker account cannot log in without its
1174    // url. Both are key-derived, so both move with the key or the account is
1175    // locked out at the NEXT boot rather than at the failing operation.
1176    rekey_setting_in_tx(&tx, "nip55_pin_check", old_key, new_key)?;
1177    rekey_setting_in_tx(&tx, "bunker_url", old_key, new_key)?;
1178    rekey_pivx_in_tx(&tx, old_key, new_key)?;
1179    rekey_community_in_tx(&tx, old_key, new_key)?;
1180
1181    // 4. Verify re-keyed state within the transaction (before committing)
1182    verify_encrypted_state_in_tx(&tx, new_key)?;
1183
1184    // 5. Update metadata within the same transaction
1185    tx.execute(
1186        "INSERT OR REPLACE INTO settings (key, value) VALUES ('security_type', ?1)",
1187        rusqlite::params![security_type],
1188    ).map_err(|e| format!("Failed to update security_type: {}", e))?;
1189
1190    // The wrap always moves with the key it protects, inside this transaction:
1191    // Some = the new key's wrap (switching TO biometric), None = drop any wrap
1192    // (credential change, or switching AWAY from biometric). A crash can never
1193    // leave a wrap that holds a key the store no longer uses.
1194    match biometric_wrap {
1195        Some(w) => {
1196            tx.execute(
1197                "INSERT OR REPLACE INTO settings (key, value) VALUES ('biometric_wrapped_key', ?1)",
1198                rusqlite::params![w],
1199            ).map_err(|e| format!("Failed to set biometric wrap: {}", e))?;
1200        }
1201        None => {
1202            tx.execute(
1203                "DELETE FROM settings WHERE key = 'biometric_wrapped_key'",
1204                [],
1205            ).map_err(|e| format!("Failed to clear biometric wrap: {}", e))?;
1206        }
1207    }
1208
1209    tx.execute(
1210        "INSERT OR REPLACE INTO settings (key, value) VALUES ('migration_state', '')",
1211        [],
1212    ).map_err(|e| format!("Failed to clear migration_state: {}", e))?;
1213
1214    // 6. COMMIT — the atomic point. Everything succeeds or nothing does.
1215    tx.commit().map_err(|e| format!("Failed to commit re-key transaction: {}", e))?;
1216
1217    Ok(())
1218}
1219
1220/// Re-key a single settings value within a transaction.
1221fn rekey_setting_in_tx(
1222    tx: &rusqlite::Transaction,
1223    key: &str,
1224    old_key: &[u8; 32],
1225    new_key: &[u8; 32],
1226) -> Result<(), String> {
1227
1228    let val: Option<String> = tx.query_row(
1229        "SELECT value FROM settings WHERE key = ?1",
1230        rusqlite::params![key],
1231        |row| row.get(0),
1232    ).ok();
1233
1234    if let Some(encrypted_val) = val {
1235        // A row that isn't ciphertext under `old_key` and isn't hex at all was
1236        // written plaintext (e.g. bunker_url stored before encryption was
1237        // enabled) — wrap it under the new key rather than failing the rekey.
1238        let looks_encrypted = encrypted_val.len() >= 56
1239            && encrypted_val.bytes().all(|b| b.is_ascii_hexdigit());
1240        let plaintext = match decrypt_with_key(&encrypted_val, old_key) {
1241            Ok(p) => p,
1242            Err(_) if !looks_encrypted => encrypted_val.clone(),
1243            Err(_) => return Err(format!("Failed to decrypt setting '{}'", key)),
1244        };
1245
1246        let re_encrypted = encrypt_with_key(&plaintext, new_key);
1247
1248        tx.execute(
1249            "UPDATE settings SET value = ?1 WHERE key = ?2",
1250            rusqlite::params![re_encrypted, key],
1251        ).map_err(|e| format!("Failed to update setting '{}': {}", key, e))?;
1252    }
1253
1254    Ok(())
1255}
1256
1257/// Re-key all PIVX promo private keys within a transaction.
1258fn rekey_pivx_in_tx(
1259    tx: &rusqlite::Transaction,
1260    old_key: &[u8; 32],
1261    new_key: &[u8; 32],
1262) -> Result<(), String> {
1263
1264    let keys: Vec<(i64, String)> = {
1265        let mut stmt = tx.prepare("SELECT id, privkey_encrypted FROM pivx_promos")
1266            .map_err(|e| format!("Failed to prepare PIVX query: {}", e))?;
1267        let rows = stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))
1268            .map_err(|e| format!("Failed to query PIVX promos: {}", e))?;
1269        rows.filter_map(|r| r.ok()).collect()
1270    };
1271
1272    for (id, key_val) in keys {
1273        // Only re-key if longer than a raw key (64 chars) — meaning it's encrypted
1274        if key_val.len() > 64 {
1275            let decrypted = decrypt_with_key(&key_val, old_key)
1276                .map_err(|_| format!("Failed to decrypt PIVX key {}", id))?;
1277            let re_encrypted = encrypt_with_key(&decrypted, new_key);
1278
1279            tx.execute(
1280                "UPDATE pivx_promos SET privkey_encrypted = ?1 WHERE id = ?2",
1281                rusqlite::params![re_encrypted, id],
1282            ).map_err(|e| format!("Failed to update PIVX key: {}", e))?;
1283        }
1284    }
1285
1286    Ok(())
1287}
1288
1289#[cfg(test)]
1290mod rekey_setting_tests {
1291    use super::*;
1292    const OLD: [u8; 32] = [3u8; 32];
1293    const NEW: [u8; 32] = [5u8; 32];
1294
1295    fn conn() -> rusqlite::Connection {
1296        let c = rusqlite::Connection::open_in_memory().unwrap();
1297        c.execute_batch("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT);").unwrap();
1298        c
1299    }
1300    fn put(c: &rusqlite::Connection, k: &str, v: &str) {
1301        c.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
1302            rusqlite::params![k, v]).unwrap();
1303    }
1304    fn get(c: &rusqlite::Connection, k: &str) -> String {
1305        c.query_row("SELECT value FROM settings WHERE key = ?1", rusqlite::params![k], |r| r.get(0)).unwrap()
1306    }
1307
1308    /// The bug that shipped: a keyless account's canary stayed under the dead
1309    /// key, so the NEXT cold boot rejected the correct credential.
1310    #[test]
1311    fn canary_survives_rekey() {
1312        let mut c = conn();
1313        put(&c, "nip55_pin_check", &encrypt_with_key(NIP55_PIN_CANARY, &OLD));
1314        let tx = c.transaction().unwrap();
1315        rekey_setting_in_tx(&tx, "nip55_pin_check", &OLD, &NEW).unwrap();
1316        tx.commit().unwrap();
1317        assert_eq!(
1318            decrypt_with_key(&get(&c, "nip55_pin_check"), &NEW).unwrap(),
1319            NIP55_PIN_CANARY
1320        );
1321    }
1322
1323    /// Same shape for bunker accounts: an un-rekeyed url = permanent login failure.
1324    #[test]
1325    fn bunker_url_survives_rekey() {
1326        let mut c = conn();
1327        let url = "bunker://abc?relay=wss://relay.example";
1328        put(&c, "bunker_url", &encrypt_with_key(url, &OLD));
1329        let tx = c.transaction().unwrap();
1330        rekey_setting_in_tx(&tx, "bunker_url", &OLD, &NEW).unwrap();
1331        tx.commit().unwrap();
1332        assert_eq!(decrypt_with_key(&get(&c, "bunker_url"), &NEW).unwrap(), url);
1333    }
1334
1335    /// A row written before encryption was enabled is plaintext: wrap it under
1336    /// the new key rather than failing the whole migration.
1337    #[test]
1338    fn plaintext_row_is_adopted_not_fatal() {
1339        let mut c = conn();
1340        let url = "bunker://plain?relay=wss://r.example";
1341        put(&c, "bunker_url", url);
1342        let tx = c.transaction().unwrap();
1343        rekey_setting_in_tx(&tx, "bunker_url", &OLD, &NEW).unwrap();
1344        tx.commit().unwrap();
1345        assert_eq!(decrypt_with_key(&get(&c, "bunker_url"), &NEW).unwrap(), url);
1346    }
1347
1348    /// Ciphertext that does NOT belong to `old_key` must abort the rekey — the
1349    /// alternative is committing a value nobody can ever decrypt.
1350    #[test]
1351    fn wrong_old_key_aborts() {
1352        let mut c = conn();
1353        put(&c, "pkey", &encrypt_with_key("nsec1example", &[9u8; 32]));
1354        let tx = c.transaction().unwrap();
1355        assert!(rekey_setting_in_tx(&tx, "pkey", &OLD, &NEW).is_err());
1356    }
1357
1358    /// Absent rows are a no-op (accounts legitimately lack pkey, seed, canary).
1359    #[test]
1360    fn missing_row_is_noop() {
1361        let mut c = conn();
1362        let tx = c.transaction().unwrap();
1363        rekey_setting_in_tx(&tx, "nip55_pin_check", &OLD, &NEW).unwrap();
1364        tx.commit().unwrap();
1365        assert_eq!(
1366            c.query_row("SELECT count(*) FROM settings", [], |r| r.get::<_, i64>(0)).unwrap(), 0
1367        );
1368    }
1369}