Skip to main content

max / balanced_breakfast

Prompt for re-entry when a plugin secret cannot be decrypted Secrets encrypted under a lost master key were handled silently: the field was cleared, an error went to the log, and the config editor showed an ordinary empty input with no indication the saved value was gone. The loss itself predates the keyring 4 bump. The previous `linux-native` store kept entries in memory only and dropped them on reboot, and the file fallback is deleted once a key migrates into the credential store, so affected installs generated a fresh key on every restart and every existing bb_enc:v1 secret became unreadable. That path generated the new key with no log line at all. Report which secret fields failed to decrypt instead of discarding that information, carry it through to the frontend, and flag each affected field in the config editor with an explanation and a toast. Fields are still cleared so ciphertext never reaches the frontend. Log a warning when a fresh key is generated, since it means any existing secrets are unrecoverable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 17:44 UTC
Commit: bf0fcf6c851001a1f88e2d92eb5616db4ba1ea78
Parent: 6071a93
4 files changed, +117 insertions, -9 deletions
@@ -117,7 +117,19 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKe
117 117 }
118 118 return Ok(key);
119 119 }
120 - // No file either — generate new key and store in keychain
120 + // No file either. Either this is a first run, or the credential
121 + // store lost the key it was holding — keyring's `linux-native`
122 + // (keyutils) backing store was in-memory only and dropped every
123 + // entry on reboot, so installs that migrated to it and had the
124 + // file fallback deleted below land here with existing secrets
125 + // they can no longer read. We cannot tell the two cases apart
126 + // here; `decrypt_config_secrets` reports the fields that turn out
127 + // to be unreadable so the user can re-enter them.
128 + tracing::warn!(
129 + "No encryption key in the credential store or on disk; generating a fresh one. \
130 + Any existing plugin secrets encrypted under a previous key are unrecoverable \
131 + and will be flagged for re-entry."
132 + );
121 133 let key = generate_key();
122 134 let b64 = BASE64.encode(&*key);
123 135 if entry.set_password(&b64).is_ok() {
@@ -210,14 +222,21 @@ pub fn encrypt_config_secrets(
210 222 }
211 223
212 224 #[tracing::instrument(skip_all)]
213 - /// Decrypt Secret-type fields in a config JSON object in-place
225 + /// Decrypt Secret-type fields in a config JSON object in-place.
226 + ///
227 + /// Returns the keys of any Secret fields that carried a `bb_enc:v1:` payload we
228 + /// could not decrypt. That happens when the master key backing those payloads is
229 + /// gone (see [`load_or_create_key_from_keychain`]), and it is unrecoverable: the
230 + /// field is cleared so ciphertext never reaches the frontend, and the caller is
231 + /// expected to ask the user to re-enter the secret.
214 232 pub fn decrypt_config_secrets(
215 233 config: &mut serde_json::Value,
216 234 schema: &ConfigSchema,
217 235 key: &EncryptionKey,
218 - ) {
236 + ) -> Vec<String> {
237 + let mut needs_reentry = Vec::new();
219 238 let Some(obj) = config.as_object_mut() else {
220 - return;
239 + return needs_reentry;
221 240 };
222 241 for field in &schema.fields {
223 242 if field.field_type != ConfigFieldType::Secret {
@@ -230,13 +249,15 @@ pub fn decrypt_config_secrets(
230 249 obj.insert(field.key.clone(), serde_json::Value::String(decrypted));
231 250 }
232 251 Err(e) => {
233 - tracing::error!(field = %field.key, error = %e, "Failed to decrypt secret, clearing field to prevent ciphertext leakage. Feed may need re-configuration.");
252 + tracing::error!(field = %field.key, error = %e, "Failed to decrypt secret, clearing field to prevent ciphertext leakage. Prompting for re-entry.");
234 253 obj.insert(field.key.clone(), serde_json::Value::String(String::new()));
254 + needs_reentry.push(field.key.clone());
235 255 }
236 256 }
237 257 }
238 258 }
239 259 }
260 + needs_reentry
240 261 }
241 262
242 263 #[cfg(test)]
@@ -328,8 +349,57 @@ mod tests {
328 349 assert!(encrypted.starts_with(PREFIX));
329 350
330 351 // Decrypt and verify
331 - decrypt_config_secrets(&mut config, &schema, &key);
352 + let needs_reentry = decrypt_config_secrets(&mut config, &schema, &key);
332 353 assert_eq!(config["api_key"], "sk-12345");
354 + assert!(needs_reentry.is_empty());
355 + }
356 +
357 + #[test]
358 + fn secrets_encrypted_under_a_lost_key_are_flagged_for_reentry() {
359 + // Reproduces what installs hit after the credential store dropped the
360 + // master key: the ciphertext is still on disk, but the key that would
361 + // decrypt it is gone and a fresh one has taken its place.
362 + let schema = ConfigSchema {
363 + description: "test".to_string(),
364 + fields: vec![
365 + ConfigField {
366 + key: "url".to_string(),
367 + label: "URL".to_string(),
368 + description: None,
369 + field_type: ConfigFieldType::Url,
370 + required: true,
371 + default: None,
372 + options: vec![],
373 + placeholder: None,
374 + },
375 + ConfigField {
376 + key: "api_key".to_string(),
377 + label: "API Key".to_string(),
378 + description: None,
379 + field_type: ConfigFieldType::Secret,
380 + required: true,
381 + default: None,
382 + options: vec![],
383 + placeholder: None,
384 + },
385 + ],
386 + };
387 +
388 + let old_key = generate_key();
389 + let mut config = serde_json::json!({
390 + "url": "https://example.com/feed",
391 + "api_key": "sk-12345"
392 + });
393 + encrypt_config_secrets(&mut config, &schema, &old_key);
394 +
395 + let new_key = generate_key();
396 + let needs_reentry = decrypt_config_secrets(&mut config, &schema, &new_key);
397 +
398 + // The secret is reported for re-entry and cleared, so no ciphertext
399 + // reaches the frontend. Non-secret fields are untouched.
400 + assert_eq!(needs_reentry, vec!["api_key".to_string()]);
401 + assert_eq!(config["api_key"], "");
402 + assert_eq!(config["url"], "https://example.com/feed");
333 403 }
334 404
335 405 #[test]
@@ -150,7 +150,15 @@ impl Orchestrator {
150 150
151 151 // Decrypt Secret fields before passing to plugin
152 152 if let (Some(key), Some(schema)) = (self.encryption_key.as_ref(), &full_schema) {
153 - crate::crypto::decrypt_config_secrets(&mut config_val, schema, key);
153 + let needs_reentry =
154 + crate::crypto::decrypt_config_secrets(&mut config_val, schema, key);
155 + if !needs_reentry.is_empty() {
156 + tracing::warn!(
157 + feed = %feed.name,
158 + fields = ?needs_reentry,
159 + "Feed has unreadable secrets and will run without them until re-entered"
160 + );
161 + }
154 162 }
155 163
156 164 if let Some(obj) = config_val.as_object() {
@@ -527,6 +527,11 @@
527 527 { name: 'name', type: 'text', label: 'Feed Name', required: true, value: feed.name },
528 528 ];
529 529
530 + // Secrets the backend could not decrypt were cleared before they reached
531 + // us; the stored value is gone for good, so flag them for re-entry
532 + // instead of showing an innocuous-looking empty field.
533 + const needsReentry = new Set(feed.secretsNeedingReentry || []);
534 +
530 535 (schema.fields || []).forEach(f => {
531 536 fields.push({
532 537 name: f.key,
@@ -537,9 +542,21 @@
537 542 options: f.options,
538 543 placeholder: f.placeholder || '',
539 544 description: f.description,
545 + error: needsReentry.has(f.key)
546 + ? 'This saved secret could not be read and has been cleared. Enter it again to restore the feed.'
547 + : undefined,
540 548 });
541 549 });
542 550
551 + if (needsReentry.size > 0) {
552 + BB.ui.showToast(
553 + needsReentry.size === 1
554 + ? 'A saved secret for this feed could not be read and must be re-entered.'
555 + : needsReentry.size + ' saved secrets for this feed could not be read and must be re-entered.',
556 + 'error'
557 + );
558 + }
559 +
543 560 BB.ui.openFormModal({
544 561 title: 'Edit ' + source.name,
545 562 fields,
@@ -26,6 +26,10 @@ pub struct FeedSnapshot {
26 26 pub busser_id: String,
27 27 pub name: String,
28 28 pub config: serde_json::Value,
29 + /// Secret fields whose stored ciphertext could not be decrypted. They have
30 + /// been cleared and the user must re-enter them.
31 + #[serde(skip_serializing_if = "Vec::is_empty")]
32 + pub secrets_needing_reentry: Vec<String>,
29 33 }
30 34
31 35 /// Response returned after a bulk fetch operation.
@@ -70,13 +74,16 @@ pub async fn get_feeds_by_busser(
70 74 .into_iter()
71 75 .map(|f| {
72 76 let mut config = f.config_json();
77 + let mut secrets_needing_reentry = Vec::new();
73 78 if let (Some(k), Some(s)) = (key, &schema) {
74 - bb_core::crypto::decrypt_config_secrets(&mut config, s, k);
79 + secrets_needing_reentry =
80 + bb_core::crypto::decrypt_config_secrets(&mut config, s, k);
75 81 }
76 82 FeedSnapshot {
77 83 busser_id: f.busser_id.to_string(),
78 84 name: f.name,
79 85 config,
86 + secrets_needing_reentry,
80 87 }
81 88 })
82 89 .collect())
@@ -293,6 +300,10 @@ pub struct FeedResponse {
293 300 pub busser_id: String,
294 301 pub name: String,
295 302 pub config: serde_json::Value,
303 + /// Secret fields whose stored ciphertext could not be decrypted. They have
304 + /// been cleared and the user must re-enter them.
305 + #[serde(skip_serializing_if = "Vec::is_empty")]
306 + pub secrets_needing_reentry: Vec<String>,
296 307 }
297 308
298 309 /// Get the first feed for a busser, with decrypted config for editing.
@@ -314,8 +325,9 @@ pub async fn get_feed(
314 325 };
315 326
316 327 let mut config = feed.config_json();
328 + let mut secrets_needing_reentry = Vec::new();
317 329 if let (Some(k), Some(s)) = (key, &schema) {
318 - bb_core::crypto::decrypt_config_secrets(&mut config, s, k);
330 + secrets_needing_reentry = bb_core::crypto::decrypt_config_secrets(&mut config, s, k);
319 331 }
320 332
321 333 Ok(FeedResponse {
@@ -323,6 +335,7 @@ pub async fn get_feed(
323 335 busser_id: feed.busser_id.to_string(),
324 336 name: feed.name,
325 337 config,
338 + secrets_needing_reentry,
326 339 })
327 340 }
328 341