| 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 |
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 |
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 |
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]
|