max / balanced_breakfast
6 files changed,
+116 insertions,
-2 deletions
| @@ -241,6 +241,7 @@ dependencies = [ | |||
| 241 | 241 | "bb-interface", | |
| 242 | 242 | "chrono", | |
| 243 | 243 | "html2text", | |
| 244 | + | "keyring", | |
| 244 | 245 | "parking_lot", | |
| 245 | 246 | "rand 0.8.5", | |
| 246 | 247 | "readable-readability", |
| @@ -35,6 +35,9 @@ aes-gcm = "0.10" | |||
| 35 | 35 | base64 = "0.22" | |
| 36 | 36 | rand = "0.8" | |
| 37 | 37 | ||
| 38 | + | # Keychain access | |
| 39 | + | keyring = "3" | |
| 40 | + | ||
| 38 | 41 | # Concurrency | |
| 39 | 42 | parking_lot = "0.12" | |
| 40 | 43 |
| @@ -21,6 +21,9 @@ aes-gcm = { workspace = true } | |||
| 21 | 21 | base64 = { workspace = true } | |
| 22 | 22 | rand = { workspace = true } | |
| 23 | 23 | ||
| 24 | + | # Keychain access for encryption key | |
| 25 | + | keyring.workspace = true | |
| 26 | + | ||
| 24 | 27 | # Lock primitives (no poisoning) | |
| 25 | 28 | parking_lot.workspace = true | |
| 26 | 29 |
| @@ -51,6 +51,57 @@ pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> { | |||
| 51 | 51 | } | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | const KEYCHAIN_SERVICE: &str = "balanced-breakfast"; | |
| 55 | + | const KEYCHAIN_KEY: &str = "encryption:master"; | |
| 56 | + | ||
| 57 | + | /// Load an encryption key from the OS keychain. Falls back to file-based storage | |
| 58 | + | /// if the keychain is unavailable. Migrates from file to keychain on first run. | |
| 59 | + | pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<[u8; 32], String> { | |
| 60 | + | // Try keychain first | |
| 61 | + | if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_KEY) { | |
| 62 | + | match entry.get_password() { | |
| 63 | + | Ok(b64) => { | |
| 64 | + | let bytes = BASE64.decode(&b64).map_err(|e| format!("Keychain key decode failed: {e}"))?; | |
| 65 | + | if bytes.len() != 32 { | |
| 66 | + | return Err(format!("Keychain key wrong size: {} (expected 32)", bytes.len())); | |
| 67 | + | } | |
| 68 | + | let mut key = [0u8; 32]; | |
| 69 | + | key.copy_from_slice(&bytes); | |
| 70 | + | return Ok(key); | |
| 71 | + | } | |
| 72 | + | Err(keyring::Error::NoEntry) => { | |
| 73 | + | // No key in keychain — check if file exists to migrate | |
| 74 | + | if file_path.exists() { | |
| 75 | + | let key = load_or_create_key(file_path)?; | |
| 76 | + | // Migrate to keychain | |
| 77 | + | let b64 = BASE64.encode(key); | |
| 78 | + | if entry.set_password(&b64).is_ok() { | |
| 79 | + | // Delete the file now that it's in the keychain | |
| 80 | + | let _ = std::fs::remove_file(file_path); | |
| 81 | + | tracing::info!("Migrated encryption key from file to keychain"); | |
| 82 | + | } | |
| 83 | + | return Ok(key); | |
| 84 | + | } | |
| 85 | + | // No file either — generate new key and store in keychain | |
| 86 | + | let key = generate_key(); | |
| 87 | + | let b64 = BASE64.encode(key); | |
| 88 | + | if entry.set_password(&b64).is_ok() { | |
| 89 | + | return Ok(key); | |
| 90 | + | } | |
| 91 | + | // Keychain write failed — fall back to file | |
| 92 | + | tracing::warn!("Keychain write failed, falling back to file-based key"); | |
| 93 | + | } | |
| 94 | + | Err(_) => { | |
| 95 | + | // Keychain read error — fall back to file | |
| 96 | + | tracing::warn!("Keychain unavailable, falling back to file-based key"); | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Fallback: use file-based key storage | |
| 102 | + | load_or_create_key(file_path) | |
| 103 | + | } | |
| 104 | + | ||
| 54 | 105 | /// Encrypt a plaintext string field using AES-256-GCM. | |
| 55 | 106 | /// Returns a string in the format `bb_enc:v1:<base64(nonce || ciphertext || tag)>`. | |
| 56 | 107 | pub fn encrypt_field(plaintext: &str, key: &[u8; 32]) -> Result<String, String> { |
| @@ -9,11 +9,67 @@ | |||
| 9 | 9 | 'use strict'; | |
| 10 | 10 | ||
| 11 | 11 | /** | |
| 12 | + | * Show a one-time warning about the plugin threat model before the user | |
| 13 | + | * adds their first feed. Stored in localStorage so it only appears once. | |
| 14 | + | * @returns {Promise<boolean>} true if the user accepts (or has already accepted). | |
| 15 | + | */ | |
| 16 | + | async function checkPluginWarning() { | |
| 17 | + | if (localStorage.getItem('bb_plugin_warning_ack')) return true; | |
| 18 | + | ||
| 19 | + | const sources = BB.state.sources || []; | |
| 20 | + | if (sources.length > 0) { | |
| 21 | + | // Already has feeds — no need to warn | |
| 22 | + | localStorage.setItem('bb_plugin_warning_ack', '1'); | |
| 23 | + | return true; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | return new Promise((resolve) => { | |
| 27 | + | const overlay = document.getElementById('modal-overlay'); | |
| 28 | + | const title = document.getElementById('modal-title'); | |
| 29 | + | const body = document.getElementById('modal-body'); | |
| 30 | + | ||
| 31 | + | title.textContent = 'Before You Add a Plugin'; | |
| 32 | + | body.innerHTML = | |
| 33 | + | '<p style="margin-bottom: 0.75rem;">Plugins are Rhai scripts that run inside a sandboxed environment. ' + | |
| 34 | + | 'They <strong>cannot</strong> access your filesystem or run programs, but they <strong>can</strong> make HTTP requests to fetch feed data.</p>' + | |
| 35 | + | '<p style="margin-bottom: 0.75rem;">Only add plugins from sources you trust. A malicious plugin could ' + | |
| 36 | + | 'send your configured API keys to a third-party server.</p>' + | |
| 37 | + | '<p style="margin-bottom: 1rem; color: var(--text-secondary);">This warning only appears once.</p>'; | |
| 38 | + | ||
| 39 | + | const actions = document.createElement('div'); | |
| 40 | + | actions.className = 'form-actions'; | |
| 41 | + | ||
| 42 | + | const cancelBtn = document.createElement('button'); | |
| 43 | + | cancelBtn.type = 'button'; | |
| 44 | + | cancelBtn.className = 'btn'; | |
| 45 | + | cancelBtn.textContent = 'Cancel'; | |
| 46 | + | cancelBtn.onclick = () => { BB.ui.closeModal(); resolve(false); }; | |
| 47 | + | actions.appendChild(cancelBtn); | |
| 48 | + | ||
| 49 | + | const acceptBtn = document.createElement('button'); | |
| 50 | + | acceptBtn.type = 'button'; | |
| 51 | + | acceptBtn.className = 'btn btn-primary'; | |
| 52 | + | acceptBtn.textContent = 'I Understand'; | |
| 53 | + | acceptBtn.onclick = () => { | |
| 54 | + | localStorage.setItem('bb_plugin_warning_ack', '1'); | |
| 55 | + | BB.ui.closeModal(); | |
| 56 | + | resolve(true); | |
| 57 | + | }; | |
| 58 | + | actions.appendChild(acceptBtn); | |
| 59 | + | ||
| 60 | + | body.appendChild(actions); | |
| 61 | + | overlay.style.display = 'flex'; | |
| 62 | + | }); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /** | |
| 12 | 66 | * Open the "Add Feed" modal. Step 1: show plugin picker. | |
| 13 | 67 | * On plugin click, proceeds to step 2 (plugin-specific config form). | |
| 14 | 68 | */ | |
| 15 | 69 | async function openAddFeed() { | |
| 16 | 70 | try { | |
| 71 | + | if (!await checkPluginWarning()) return; | |
| 72 | + | ||
| 17 | 73 | const plugins = await BB.api.plugins.list(); | |
| 18 | 74 | ||
| 19 | 75 | if (plugins.length === 0) { |
| @@ -53,9 +53,9 @@ impl AppState { | |||
| 53 | 53 | .await | |
| 54 | 54 | .map_err(|e| format!("Failed to create orchestrator: {}", e))?; | |
| 55 | 55 | ||
| 56 | - | // Load or create encryption key for plugin secrets | |
| 56 | + | // Load or create encryption key for plugin secrets (keychain preferred, file fallback) | |
| 57 | 57 | let key_path = app_data_dir.join("encryption.key"); | |
| 58 | - | match bb_core::crypto::load_or_create_key(&key_path) { | |
| 58 | + | match bb_core::crypto::load_or_create_key_from_keychain(&key_path) { | |
| 59 | 59 | Ok(key) => { | |
| 60 | 60 | info!("Encryption key loaded"); | |
| 61 | 61 | orchestrator.set_encryption_key(key); |