Files
shell-rs/src/secrets/mod.rs
T
XuzandClaude Opus 5.5 d5370f8bac 外部连接:改名并在打开时收起左侧栏
命名(用户定的):命令行链接打开的叫「外部连接」(不保存、只开终端);以后在
ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。代码里共同的「不
保存」统一叫 temporary(HostStore 的 temporary / is_temporary、
SecretRef::Temporary、TemporarySecretStore,和 CLI 的 JSON 字段一致),「只开
终端」改由来源决定:HostStore::is_external,login_of 只对它置 shell_only,右
侧栏也看它。shellrs list 表格写「(外部连接)」。

外部连接打开时左侧栏开着就收起(同 ⌘B),终端占满窗口;链接有误时不收。
命令片段面板本来就列出全部片段,外部连接照常都能看到;以后做适用范围时外部
和临时连接只显示「所有主机」的,规则记在 CLAUDE.md。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:17:03 +08:00

215 lines
7.5 KiB
Rust

//! 系统钥匙串(macOS Keychain Services / Windows 凭据管理器 / Secret Service)。
//!
//! ShellRS 的秘密只存在这里。数据库里永远不出现密码或口令,`host/database.rs`
//! 的 `schema_never_contains_secret_columns` 守着这条不变量。
mod keychain;
mod memory;
mod temporary;
use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use zeroize::Zeroizing;
pub use keychain::KeychainSecretStore;
pub use memory::{InMemorySecretStore, NoSecretStore};
pub use temporary::TemporarySecretStore;
/// 钥匙串里的服务名,所有条目共用。改了会让已保存的秘密全部失联。
pub const SERVICE: &str = "shellrs";
/// 一条秘密的身份。
///
/// 按「用哪个用户连到哪个端点」而不是「哪条保存的主机」归属:主机改名、复制都
/// 不丢密码,端点和用户相同的几条共用一条;私钥口令按文件路径归属,同一把钥匙
/// 只问一次。密码凭据的密码归凭据自己,用它的主机共用这一条。代理的密码按「代理
/// 地址 + 用户名」归属,经由同一个代理的主机共用。
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SecretRef {
/// 某个 SSH 端点的登录密码。
Password {
user: String,
host: String,
port: u16,
},
/// 某个私钥文件的口令。
Passphrase { key_path: String },
/// 某条密码凭据的密码,按凭据随机生成、永不复用的 `keychain_id` 归属。
Credential { keychain_id: String },
/// 某个 HTTP / SOCKS5 代理的认证密码。
Proxy {
user: String,
host: String,
port: u16,
},
/// 不保存的连接(外部连接,以后还有临时连接)的密码,按这台临时主机的 id
/// 归属。只在 [`TemporarySecretStore`] 的内存里,从不进钥匙串。
Temporary { host: u64 },
}
impl SecretRef {
pub fn password(user: impl Into<String>, host: impl Into<String>, port: u16) -> Self {
Self::Password {
user: user.into(),
host: host.into(),
port,
}
}
pub fn passphrase(key_path: impl AsRef<Path>) -> Self {
Self::Passphrase {
key_path: key_path.as_ref().display().to_string(),
}
}
pub fn credential(keychain_id: impl Into<String>) -> Self {
Self::Credential {
keychain_id: keychain_id.into(),
}
}
pub fn proxy(user: impl Into<String>, host: impl Into<String>, port: u16) -> Self {
Self::Proxy {
user: user.into(),
host: host.into(),
port,
}
}
pub fn temporary(host: u64) -> Self {
Self::Temporary { host }
}
/// 钥匙串条目的账户名。前缀区分种类,在 Keychain Access 里直接可读。
pub fn account(&self) -> String {
match self {
Self::Password { user, host, port } => format!("password:{user}@{host}:{port}"),
Self::Passphrase { key_path } => format!("passphrase:{key_path}"),
Self::Credential { keychain_id } => format!("credential:{keychain_id}"),
Self::Proxy { user, host, port } => format!("proxy:{user}@{host}:{port}"),
Self::Temporary { host } => format!("temporary:{host}"),
}
}
}
/// 系统钥匙串的读写。
///
/// 每个方法都是阻塞的,在 macOS 上还可能弹出系统授权框,所以只能在后台执行器
/// 或传输层自己的工作线程上调用,**永远不要在 UI 线程上调用**。
pub trait SecretStore: Send + Sync + 'static {
/// 读一条秘密。没存过是 `Ok(None)`,不是错误。
fn get(&self, secret: &SecretRef) -> Result<Option<Zeroizing<String>>>;
fn set(&self, secret: &SecretRef, value: &str) -> Result<()>;
/// 删一条秘密。条目本来就不存在也算成功。
fn delete(&self, secret: &SecretRef) -> Result<()>;
/// 这台机器上钥匙串是否可用。为 false 时 `set` 一定失败。
fn is_available(&self) -> bool;
}
pub type SharedSecretStore = Arc<dyn SecretStore>;
/// 生产环境用的秘密存储:能用系统钥匙串就用,用不了就退化成一个什么都不存的
/// 实现,界面据此把密码字段禁用掉。
pub fn system_secret_store() -> SharedSecretStore {
let keychain = KeychainSecretStore::new();
if keychain.is_available() {
Arc::new(keychain)
} else {
Arc::new(NoSecretStore)
}
}
#[cfg(test)]
mod tests {
use super::{InMemorySecretStore, NoSecretStore, SecretRef, SecretStore};
#[test]
fn password_accounts_name_the_endpoint() {
let secret = SecretRef::password("root", "10.0.1.12", 22);
assert_eq!(secret.account(), "password:root@10.0.1.12:22");
}
#[test]
fn passphrase_accounts_name_the_key_file() {
let secret = SecretRef::passphrase("/Users/me/.ssh/id_ed25519");
assert_eq!(secret.account(), "passphrase:/Users/me/.ssh/id_ed25519");
}
#[test]
fn credential_accounts_name_the_keychain_id() {
let secret = SecretRef::credential("Jwg5rHvXCxw89paM");
assert_eq!(secret.account(), "credential:Jwg5rHvXCxw89paM");
}
#[test]
fn proxy_accounts_name_the_proxy_and_its_user() {
let secret = SecretRef::proxy("me", "127.0.0.1", 7890);
assert_eq!(secret.account(), "proxy:me@127.0.0.1:7890");
}
#[test]
fn accounts_of_different_kinds_never_collide() {
let accounts = [
SecretRef::password("root", "10.0.1.12", 22),
SecretRef::passphrase("root@10.0.1.12:22"),
SecretRef::credential("root@10.0.1.12:22"),
SecretRef::proxy("root", "10.0.1.12", 22),
SecretRef::temporary(22),
]
.map(|secret| secret.account());
for (ix, account) in accounts.iter().enumerate() {
assert!(!accounts[ix + 1..].contains(account), "{account}");
}
}
#[test]
fn the_memory_store_round_trips() {
let store = InMemorySecretStore::default();
let secret = SecretRef::password("root", "10.0.1.12", 22);
assert!(store.get(&secret).unwrap().is_none());
store.set(&secret, "hunter2").unwrap();
assert_eq!(
store.get(&secret).unwrap().as_deref().map(String::as_str),
Some("hunter2")
);
store.set(&secret, "hunter3").unwrap();
assert_eq!(
store.get(&secret).unwrap().as_deref().map(String::as_str),
Some("hunter3")
);
store.delete(&secret).unwrap();
assert!(store.get(&secret).unwrap().is_none());
}
#[test]
fn deleting_a_missing_secret_succeeds() {
let store = InMemorySecretStore::default();
let secret = SecretRef::passphrase("/tmp/id_ed25519");
assert!(store.delete(&secret).is_ok());
}
#[test]
fn the_memory_store_keys_by_account() {
let store = InMemorySecretStore::default();
let one = SecretRef::password("root", "10.0.1.12", 22);
let other = SecretRef::password("root", "10.0.1.12", 2222);
store.set(&one, "hunter2").unwrap();
assert!(store.get(&other).unwrap().is_none());
}
#[test]
fn the_disabled_store_reads_empty_and_refuses_writes() {
let store = NoSecretStore;
let secret = SecretRef::password("root", "10.0.1.12", 22);
assert!(!store.is_available());
assert!(store.get(&secret).unwrap().is_none());
assert!(store.set(&secret, "hunter2").is_err());
assert!(store.delete(&secret).is_ok());
}
}