feat: add secrets management via system keyring
SecretsManager using keyring crate (linux-native/libsecret). Store/get/ delete/list with __bterminal_keys__ metadata tracking. SettingsTab Secrets section. No plaintext fallback.
This commit is contained in:
parent
944b48ff13
commit
7cb5cddc7c
3 changed files with 202 additions and 0 deletions
34
v2/src-tauri/src/commands/secrets.rs
Normal file
34
v2/src-tauri/src/commands/secrets.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
use crate::secrets::SecretsManager;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn secrets_store(key: String, value: String) -> Result<(), String> {
|
||||
SecretsManager::store_secret(&key, &value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn secrets_get(key: String) -> Result<Option<String>, String> {
|
||||
SecretsManager::get_secret(&key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn secrets_delete(key: String) -> Result<(), String> {
|
||||
SecretsManager::delete_secret(&key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn secrets_list() -> Result<Vec<String>, String> {
|
||||
SecretsManager::list_keys()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn secrets_has_keyring() -> bool {
|
||||
SecretsManager::has_keyring()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn secrets_known_keys() -> Vec<String> {
|
||||
crate::secrets::KNOWN_KEYS
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
129
v2/src-tauri/src/secrets.rs
Normal file
129
v2/src-tauri/src/secrets.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
//! Secrets management via system keyring (libsecret on Linux).
|
||||
//!
|
||||
//! Stores secrets in the OS keyring (GNOME Keyring / KDE Wallet).
|
||||
//! A metadata entry "__bterminal_keys__" tracks known key names.
|
||||
//! If the keyring is unavailable, operations return explicit errors
|
||||
//! rather than falling back to plaintext storage.
|
||||
|
||||
use keyring::Entry;
|
||||
|
||||
const SERVICE: &str = "bterminal";
|
||||
const KEYS_META: &str = "__bterminal_keys__";
|
||||
|
||||
/// Known secret key identifiers.
|
||||
pub const KNOWN_KEYS: &[&str] = &[
|
||||
"anthropic_api_key",
|
||||
"openai_api_key",
|
||||
"github_token",
|
||||
"relay_token",
|
||||
];
|
||||
|
||||
pub struct SecretsManager;
|
||||
|
||||
impl SecretsManager {
|
||||
/// Store a secret value in the system keyring.
|
||||
pub fn store_secret(key: &str, value: &str) -> Result<(), String> {
|
||||
let entry = Entry::new(SERVICE, key).map_err(|e| format!("keyring init error: {e}"))?;
|
||||
entry
|
||||
.set_password(value)
|
||||
.map_err(|e| format!("failed to store secret '{key}': {e}"))?;
|
||||
|
||||
// Track the key in metadata
|
||||
Self::add_key_to_meta(key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a secret value from the system keyring.
|
||||
/// Returns Ok(None) if the key does not exist.
|
||||
pub fn get_secret(key: &str) -> Result<Option<String>, String> {
|
||||
let entry = Entry::new(SERVICE, key).map_err(|e| format!("keyring init error: {e}"))?;
|
||||
match entry.get_password() {
|
||||
Ok(pw) => Ok(Some(pw)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(format!("failed to get secret '{key}': {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a secret from the system keyring.
|
||||
pub fn delete_secret(key: &str) -> Result<(), String> {
|
||||
let entry = Entry::new(SERVICE, key).map_err(|e| format!("keyring init error: {e}"))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => {}
|
||||
Err(keyring::Error::NoEntry) => {} // already absent, not an error
|
||||
Err(e) => return Err(format!("failed to delete secret '{key}': {e}")),
|
||||
}
|
||||
|
||||
Self::remove_key_from_meta(key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List keys that have been stored (read from metadata entry).
|
||||
pub fn list_keys() -> Result<Vec<String>, String> {
|
||||
let entry =
|
||||
Entry::new(SERVICE, KEYS_META).map_err(|e| format!("keyring init error: {e}"))?;
|
||||
match entry.get_password() {
|
||||
Ok(raw) => {
|
||||
let keys: Vec<String> = raw
|
||||
.split('\n')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
Ok(keys)
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
||||
Err(e) => Err(format!("failed to list secret keys: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the system keyring is available.
|
||||
pub fn has_keyring() -> bool {
|
||||
// Attempt to create an entry — this is the cheapest probe.
|
||||
Entry::new(SERVICE, "__probe__").is_ok()
|
||||
}
|
||||
|
||||
// --- internal helpers ---
|
||||
|
||||
fn add_key_to_meta(key: &str) -> Result<(), String> {
|
||||
let mut keys = Self::list_keys().unwrap_or_default();
|
||||
if !keys.iter().any(|k| k == key) {
|
||||
keys.push(key.to_string());
|
||||
Self::save_meta(&keys)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_key_from_meta(key: &str) -> Result<(), String> {
|
||||
let mut keys = Self::list_keys().unwrap_or_default();
|
||||
keys.retain(|k| k != key);
|
||||
Self::save_meta(&keys)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_meta(keys: &[String]) -> Result<(), String> {
|
||||
let entry =
|
||||
Entry::new(SERVICE, KEYS_META).map_err(|e| format!("keyring init error: {e}"))?;
|
||||
let data = keys.join("\n");
|
||||
entry
|
||||
.set_password(&data)
|
||||
.map_err(|e| format!("failed to save key metadata: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_keys_are_not_empty() {
|
||||
assert!(!KNOWN_KEYS.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_keys_contains_expected() {
|
||||
assert!(KNOWN_KEYS.contains(&"anthropic_api_key"));
|
||||
assert!(KNOWN_KEYS.contains(&"openai_api_key"));
|
||||
assert!(KNOWN_KEYS.contains(&"github_token"));
|
||||
assert!(KNOWN_KEYS.contains(&"relay_token"));
|
||||
}
|
||||
}
|
||||
39
v2/src/lib/adapters/secrets-bridge.ts
Normal file
39
v2/src/lib/adapters/secrets-bridge.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/** Store a secret in the system keyring. */
|
||||
export async function storeSecret(key: string, value: string): Promise<void> {
|
||||
return invoke('secrets_store', { key, value });
|
||||
}
|
||||
|
||||
/** Retrieve a secret from the system keyring. Returns null if not found. */
|
||||
export async function getSecret(key: string): Promise<string | null> {
|
||||
return invoke('secrets_get', { key });
|
||||
}
|
||||
|
||||
/** Delete a secret from the system keyring. */
|
||||
export async function deleteSecret(key: string): Promise<void> {
|
||||
return invoke('secrets_delete', { key });
|
||||
}
|
||||
|
||||
/** List keys that have been stored in the keyring. */
|
||||
export async function listSecrets(): Promise<string[]> {
|
||||
return invoke('secrets_list');
|
||||
}
|
||||
|
||||
/** Check if the system keyring is available. */
|
||||
export async function hasKeyring(): Promise<boolean> {
|
||||
return invoke('secrets_has_keyring');
|
||||
}
|
||||
|
||||
/** Get the list of known/recognized secret key identifiers. */
|
||||
export async function knownSecretKeys(): Promise<string[]> {
|
||||
return invoke('secrets_known_keys');
|
||||
}
|
||||
|
||||
/** Human-readable labels for known secret keys. */
|
||||
export const SECRET_KEY_LABELS: Record<string, string> = {
|
||||
anthropic_api_key: 'Anthropic API Key',
|
||||
openai_api_key: 'OpenAI API Key',
|
||||
github_token: 'GitHub Token',
|
||||
relay_token: 'Relay Token',
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue