|
| 1 | +//! Re-encrypt data that was written under the file-based fallback key. |
| 2 | +//! |
| 3 | +//! On macOS/Linux, `create_key_provider` can briefly fall back to `keys/master.key` |
| 4 | +//! when the OS keychain prompt is dismissed. Credentials encrypted during that |
| 5 | +//! window cannot be read once the keychain key is used again. This module detects |
| 6 | +//! those rows and re-encrypts them with the active key. |
| 7 | +
|
| 8 | +use std::path::Path; |
| 9 | + |
| 10 | +#[cfg(not(windows))] |
| 11 | +use anyhow::Context; |
| 12 | +use anyhow::Result; |
| 13 | +#[cfg(not(windows))] |
| 14 | +use tracing::info; |
| 15 | + |
| 16 | +use crate::crypto::FieldEncryptor; |
| 17 | +#[cfg(not(windows))] |
| 18 | +use crate::keychain::MasterKeyProvider; |
| 19 | +#[cfg(not(windows))] |
| 20 | +use crate::keychain_file::FileKeyProvider; |
| 21 | +use crate::Database; |
| 22 | + |
| 23 | +/// Re-encrypt credential and installed-server fields that were encrypted with the |
| 24 | +/// legacy file fallback key so they work under the active keychain key. |
| 25 | +#[cfg(not(windows))] |
| 26 | +pub fn migrate_file_key_encrypted_fields( |
| 27 | + db: &Database, |
| 28 | + data_dir: &Path, |
| 29 | + active_encryptor: &FieldEncryptor, |
| 30 | +) -> Result<u32> { |
| 31 | + let file_provider = FileKeyProvider::new(data_dir)?; |
| 32 | + if !file_provider.key_exists() { |
| 33 | + return Ok(0); |
| 34 | + } |
| 35 | + |
| 36 | + let file_key = file_provider.get_or_create_key()?; |
| 37 | + let file_encryptor = FieldEncryptor::new(&file_key)?; |
| 38 | + |
| 39 | + let mut migrated = 0u32; |
| 40 | + migrated += migrate_credentials(db, active_encryptor, &file_encryptor)?; |
| 41 | + migrated += migrate_installed_server_inputs(db, active_encryptor, &file_encryptor)?; |
| 42 | + |
| 43 | + if migrated > 0 { |
| 44 | + info!( |
| 45 | + "Re-encrypted {} credential/input field(s) from file fallback key to active master key", |
| 46 | + migrated |
| 47 | + ); |
| 48 | + } |
| 49 | + |
| 50 | + Ok(migrated) |
| 51 | +} |
| 52 | + |
| 53 | +/// Windows uses DPAPI file storage only; file-keychain fallback migration is Unix-only. |
| 54 | +#[cfg(windows)] |
| 55 | +pub fn migrate_file_key_encrypted_fields( |
| 56 | + _db: &Database, |
| 57 | + _data_dir: &Path, |
| 58 | + _active_encryptor: &FieldEncryptor, |
| 59 | +) -> Result<u32> { |
| 60 | + Ok(0) |
| 61 | +} |
| 62 | + |
| 63 | +/// Migrate encrypted credential values. |
| 64 | +#[cfg(not(windows))] |
| 65 | +fn migrate_credentials( |
| 66 | + db: &Database, |
| 67 | + active: &FieldEncryptor, |
| 68 | + file: &FieldEncryptor, |
| 69 | +) -> Result<u32> { |
| 70 | + let conn = db.connection(); |
| 71 | + let mut stmt = conn.prepare( |
| 72 | + "SELECT rowid, credential_value FROM credentials WHERE credential_value IS NOT NULL", |
| 73 | + )?; |
| 74 | + let rows: Vec<(i64, String)> = stmt |
| 75 | + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? |
| 76 | + .filter_map(|r| r.ok()) |
| 77 | + .collect(); |
| 78 | + |
| 79 | + let mut migrated = 0u32; |
| 80 | + for (rowid, value) in rows { |
| 81 | + if active.decrypt(&value).is_ok() { |
| 82 | + continue; |
| 83 | + } |
| 84 | + let plaintext = file |
| 85 | + .decrypt(&value) |
| 86 | + .with_context(|| format!("credential rowid={rowid} is not readable with either key"))?; |
| 87 | + let reencrypted = active |
| 88 | + .encrypt(&plaintext) |
| 89 | + .context("failed to re-encrypt credential with active key")?; |
| 90 | + conn.execute( |
| 91 | + "UPDATE credentials SET credential_value = ?1 WHERE rowid = ?2", |
| 92 | + rusqlite::params![reencrypted, rowid], |
| 93 | + )?; |
| 94 | + migrated += 1; |
| 95 | + } |
| 96 | + Ok(migrated) |
| 97 | +} |
| 98 | + |
| 99 | +/// Migrate encrypted installed-server input_values blobs. |
| 100 | +#[cfg(not(windows))] |
| 101 | +fn migrate_installed_server_inputs( |
| 102 | + db: &Database, |
| 103 | + active: &FieldEncryptor, |
| 104 | + file: &FieldEncryptor, |
| 105 | +) -> Result<u32> { |
| 106 | + let conn = db.connection(); |
| 107 | + let mut stmt = conn.prepare( |
| 108 | + "SELECT rowid, input_values FROM installed_servers WHERE input_values IS NOT NULL AND input_values != ''", |
| 109 | + )?; |
| 110 | + let rows: Vec<(i64, String)> = stmt |
| 111 | + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? |
| 112 | + .filter_map(|r| r.ok()) |
| 113 | + .collect(); |
| 114 | + |
| 115 | + let mut migrated = 0u32; |
| 116 | + for (rowid, value) in rows { |
| 117 | + if active.decrypt(&value).is_ok() { |
| 118 | + continue; |
| 119 | + } |
| 120 | + let plaintext = match file.decrypt(&value) { |
| 121 | + Ok(p) => p, |
| 122 | + Err(_) => continue, |
| 123 | + }; |
| 124 | + let reencrypted = active |
| 125 | + .encrypt(&plaintext) |
| 126 | + .context("failed to re-encrypt input_values with active key")?; |
| 127 | + conn.execute( |
| 128 | + "UPDATE installed_servers SET input_values = ?1 WHERE rowid = ?2", |
| 129 | + rusqlite::params![reencrypted, rowid], |
| 130 | + )?; |
| 131 | + migrated += 1; |
| 132 | + } |
| 133 | + Ok(migrated) |
| 134 | +} |
| 135 | + |
| 136 | +#[cfg(all(test, not(windows)))] |
| 137 | +mod tests { |
| 138 | + use super::*; |
| 139 | + use crate::crypto::generate_master_key; |
| 140 | + use chrono::Utc; |
| 141 | + use uuid::Uuid; |
| 142 | + |
| 143 | + #[test] |
| 144 | + fn migrate_file_key_credentials_to_active_key() { |
| 145 | + let tmp = tempfile::tempdir().unwrap(); |
| 146 | + let data_dir = tmp.path(); |
| 147 | + |
| 148 | + let file_provider = FileKeyProvider::new(data_dir).unwrap(); |
| 149 | + let file_key = file_provider.get_or_create_key().unwrap(); |
| 150 | + let file_encryptor = FieldEncryptor::new(&file_key).unwrap(); |
| 151 | + |
| 152 | + let active_key = generate_master_key().unwrap(); |
| 153 | + let active_encryptor = FieldEncryptor::new(&active_key).unwrap(); |
| 154 | + |
| 155 | + let db_path = data_dir.join("mcpmux.db"); |
| 156 | + let db = Database::open(&db_path).unwrap(); |
| 157 | + |
| 158 | + let space_id = Uuid::new_v4(); |
| 159 | + let conn = db.connection(); |
| 160 | + conn.execute( |
| 161 | + "INSERT INTO spaces (id, name, created_at, updated_at) VALUES (?1, 'test', ?2, ?2)", |
| 162 | + rusqlite::params![space_id.to_string(), Utc::now().to_rfc3339()], |
| 163 | + ) |
| 164 | + .unwrap(); |
| 165 | + |
| 166 | + let token = "test-oauth-token-value"; |
| 167 | + let encrypted = file_encryptor.encrypt(token).unwrap(); |
| 168 | + conn.execute( |
| 169 | + "INSERT INTO credentials (space_id, server_id, credential_type, credential_value, created_at, updated_at) |
| 170 | + VALUES (?1, 'demo', 'access_token', ?2, ?3, ?3)", |
| 171 | + rusqlite::params![space_id.to_string(), encrypted, Utc::now().to_rfc3339()], |
| 172 | + ) |
| 173 | + .unwrap(); |
| 174 | + |
| 175 | + let stored_before: String = conn |
| 176 | + .query_row( |
| 177 | + "SELECT credential_value FROM credentials WHERE server_id = 'demo'", |
| 178 | + [], |
| 179 | + |r| r.get(0), |
| 180 | + ) |
| 181 | + .unwrap(); |
| 182 | + assert!(active_encryptor.decrypt(&stored_before).is_err()); |
| 183 | + |
| 184 | + let migrated = migrate_file_key_encrypted_fields(&db, data_dir, &active_encryptor).unwrap(); |
| 185 | + assert_eq!(migrated, 1); |
| 186 | + |
| 187 | + let stored_after: String = conn |
| 188 | + .query_row( |
| 189 | + "SELECT credential_value FROM credentials WHERE server_id = 'demo'", |
| 190 | + [], |
| 191 | + |r| r.get(0), |
| 192 | + ) |
| 193 | + .unwrap(); |
| 194 | + assert_eq!(active_encryptor.decrypt(&stored_after).unwrap(), token); |
| 195 | + assert!(file_encryptor.decrypt(&stored_after).is_err()); |
| 196 | + } |
| 197 | +} |
0 commit comments