Skip to main content

max / balanced_breakfast

Crypto hardening, feed JS fixes, state and dependency updates Core crypto improvements, feed JavaScript fixes, state management updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-18 20:51 UTC
Commit: bb246f264ef8de2a1c0e90099783b2d155cf8ec8
Parent: 3a01696
6 files changed, +116 insertions, -2 deletions
M Cargo.lock +1
@@ -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",
M Cargo.toml +3
@@ -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);