#![allow(dead_code)]
use anyhow::{anyhow, Result};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::core::config::{Backend, DaemonConfig, LockConfig};
use crate::core::crypto::{self, VaultKey, SALT_SIZE};
use crate::core::vault::svault_dir;
const KEYRING_VERSION: u32 = 1;
const KEYRING_FILE: &str = "keyring.enc";
const KEYRING_SESSION: &str = ".keyring.session";
pub const KEY_ENV: &str = "SVAULT_OPENROUTER_KEY";
pub fn keyring_path() -> PathBuf {
svault_dir().join(KEYRING_FILE)
}
fn session_path() -> PathBuf {
svault_dir().join(KEYRING_SESSION)
}
pub fn exists() -> bool {
keyring_path().exists()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JudgeDef {
#[serde(default = "default_model")]
pub model: String,
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default = "default_judge_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_allow_threshold")]
pub allow_threshold: u8,
#[serde(default = "default_high_threshold")]
pub high_threshold: u8,
#[serde(default)]
pub criteria: String,
#[serde(default)]
pub api_key: String,
}
fn default_model() -> String {
"google/gemini-2.5-flash".to_string()
}
fn default_base_url() -> String {
"https://openrouter.ai/api/v1".to_string()
}
fn default_judge_timeout() -> u64 {
6
}
fn default_allow_threshold() -> u8 {
60
}
fn default_high_threshold() -> u8 {
80
}
impl Default for JudgeDef {
fn default() -> Self {
Self {
model: default_model(),
base_url: default_base_url(),
timeout_secs: default_judge_timeout(),
allow_threshold: default_allow_threshold(),
high_threshold: default_high_threshold(),
criteria: String::new(),
api_key: String::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KeyringData {
#[serde(default = "default_keyring_version")]
pub version: u32,
#[serde(default)]
pub lock: LockConfig,
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default)]
pub backend: Backend,
#[serde(default)]
pub judge_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_judge: Option<String>,
#[serde(default)]
pub judges: BTreeMap<String, JudgeDef>,
}
fn default_keyring_version() -> u32 {
KEYRING_VERSION
}
impl Default for KeyringData {
fn default() -> Self {
Self {
version: KEYRING_VERSION,
lock: LockConfig::default(),
daemon: DaemonConfig::default(),
backend: Backend::default(),
judge_enabled: false,
default_judge: None,
judges: BTreeMap::new(),
}
}
}
impl KeyringData {
pub fn resolve_judge(&self, assigned: Option<&str>) -> Option<(&str, &JudgeDef)> {
if !self.judge_enabled {
return None;
}
let name = assigned.or(self.default_judge.as_deref())?;
self.judges
.get_key_value(name)
.map(|(k, d)| (k.as_str(), d))
}
}
#[derive(Zeroize, ZeroizeOnDrop)]
struct SecretStore(String);
fn encrypt_data(key: &VaultKey, salt: &[u8; SALT_SIZE], data: &KeyringData) -> Result<Vec<u8>> {
let json = SecretStore(serde_json::to_string(data)?);
crypto::encrypt(key, salt, json.0.as_bytes())
}
fn decode_data(key: &VaultKey, encrypted: &[u8]) -> Result<KeyringData> {
let plaintext = crypto::decrypt(key, encrypted)?;
let store = SecretStore(String::from_utf8(plaintext)?);
Ok(serde_json::from_str(&store.0)?)
}
pub struct Keyring {
pub data: KeyringData,
key: VaultKey,
}
impl Keyring {
pub fn init_with_key(dek: VaultKey) -> Result<Self> {
let path = keyring_path();
if path.exists() {
return Err(anyhow!("a keyring already exists at {}", path.display()));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
crate::core::secfile::create_dir_owner_only(parent)?;
}
}
let mut salt = [0u8; SALT_SIZE];
rand::thread_rng().fill_bytes(&mut salt);
let data = KeyringData::default();
let blob = encrypt_data(&dek, &salt, &data)?;
crate::core::secfile::write_owner_only(&path, &blob)?;
Ok(Self { data, key: dek })
}
pub fn open_with_key(key: VaultKey) -> Result<Self> {
let encrypted = std::fs::read(keyring_path())
.map_err(|_| anyhow!("no keyring yet — run 'svault keyring init'"))?;
let data = decode_data(&key, &encrypted)?;
Ok(Self { data, key })
}
pub fn key(&self) -> &VaultKey {
&self.key
}
pub fn save(&self) -> Result<()> {
let path = keyring_path();
let encrypted = std::fs::read(&path)?;
if encrypted.len() < SALT_SIZE {
return Err(anyhow!("keyring.enc is too short — may be corrupted"));
}
let salt: [u8; SALT_SIZE] = encrypted[..SALT_SIZE]
.try_into()
.expect("slice length checked against SALT_SIZE above");
let blob = encrypt_data(&self.key, &salt, &self.data)?;
crate::core::secfile::write_owner_only(&path, &blob)?;
Ok(())
}
}
pub fn unlock_session(key: &[u8; 32]) -> Result<()> {
crate::core::session::write_session_key(&session_path(), key)
}
pub fn lock_session() -> Result<()> {
crate::core::session::secure_remove(&session_path())?;
Ok(())
}
pub fn session_key() -> Option<[u8; 32]> {
crate::core::session::read_session_key(&session_path())
}
pub fn is_unlocked() -> bool {
session_key().is_some()
}
pub fn open_from_session() -> Option<Keyring> {
let bytes = session_key()?;
Keyring::open_with_key(VaultKey::from_bytes(bytes)).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::testlock::CWD_LOCK;
use crate::core::vault::SVAULT_DIR;
use std::sync::MutexGuard;
fn in_temp_cwd() -> (MutexGuard<'static, ()>, tempfile::TempDir, PathBuf) {
let guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::TempDir::new().unwrap();
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
(guard, tmp, prev)
}
fn sample_judge() -> JudgeDef {
JudgeDef {
model: "google/gemini-2.5-flash".into(),
criteria: "Only allow billing-related reasons.".into(),
api_key: "sk-or-secret-XYZ".into(),
..JudgeDef::default()
}
}
#[test]
fn init_open_roundtrips_and_wrong_key_rejected() {
let (_g, _tmp, prev) = in_temp_cwd();
let dek = crate::core::master::new_dek();
let dek_bytes = *dek.bytes();
let mut kr = Keyring::init_with_key(dek).unwrap();
kr.data.judge_enabled = true;
kr.data.default_judge = Some("strict".into());
kr.data.judges.insert("strict".into(), sample_judge());
kr.save().unwrap();
assert!(Keyring::open_with_key(VaultKey::from_bytes([0u8; 32])).is_err());
let reopened = Keyring::open_with_key(VaultKey::from_bytes(dek_bytes)).unwrap();
assert!(reopened.data.judge_enabled);
assert_eq!(reopened.data.default_judge.as_deref(), Some("strict"));
let j = reopened.data.judges.get("strict").unwrap();
assert_eq!(j.criteria, "Only allow billing-related reasons.");
assert_eq!(j.api_key, "sk-or-secret-XYZ");
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn nothing_sensitive_is_readable_at_rest() {
let (_g, _tmp, prev) = in_temp_cwd();
let mut kr = Keyring::init_with_key(crate::core::master::new_dek()).unwrap();
kr.data.judges.insert("j".into(), sample_judge());
kr.save().unwrap();
let raw = std::fs::read(keyring_path()).unwrap();
for needle in [
b"sk-or-secret-XYZ".as_slice(),
b"billing-related".as_slice(),
b"gemini-2.5-flash".as_slice(),
] {
assert!(
raw.windows(needle.len()).all(|w| w != needle),
"keyring.enc leaked {:?} at rest",
String::from_utf8_lossy(needle)
);
}
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn keyring_stays_readable_after_master_rekey() {
let (_g, _tmp, prev) = in_temp_cwd();
let m = crate::core::master::Master::init("Old!Master#1").unwrap();
let dek = crate::core::master::new_dek();
let dek_bytes = *dek.bytes();
let mut kr = Keyring::init_with_key(dek).unwrap();
kr.data.judges.insert("j".into(), sample_judge());
kr.save().unwrap();
m.wrap_keyring_dek(&VaultKey::from_bytes(dek_bytes))
.unwrap();
m.rekey("New!Master#2").unwrap();
let reopened = crate::core::master::Master::open("New!Master#2").unwrap();
let recovered = reopened.unwrap_keyring_dek().unwrap();
assert_eq!(recovered.bytes(), &dek_bytes);
let r = Keyring::open_with_key(recovered).unwrap();
assert!(r.data.judges.contains_key("j"));
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn session_caches_key_then_lock_clears() {
let (_g, _tmp, prev) = in_temp_cwd();
crate::core::secfile::create_dir_owner_only(&PathBuf::from(SVAULT_DIR)).unwrap();
assert!(!is_unlocked());
unlock_session(&[9u8; 32]).unwrap();
assert!(is_unlocked());
assert_eq!(session_key(), Some([9u8; 32]));
lock_session().unwrap();
assert!(!is_unlocked());
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn resolve_judge_prefers_assigned_then_default() {
let mut data = KeyringData {
judge_enabled: true,
default_judge: Some("def".into()),
..KeyringData::default()
};
data.judges.insert("def".into(), JudgeDef::default());
data.judges.insert("other".into(), JudgeDef::default());
assert_eq!(data.resolve_judge(Some("other")).unwrap().0, "other");
assert_eq!(data.resolve_judge(None).unwrap().0, "def");
assert!(data.resolve_judge(Some("missing")).is_none());
data.judge_enabled = false;
assert!(data.resolve_judge(Some("def")).is_none());
}
}