mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
257 lines
8.6 KiB
Rust
257 lines
8.6 KiB
Rust
use std::{collections::HashMap, io::Write, sync::Mutex};
|
|
|
|
use nostr::{Keys, ToBech32};
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
use crate::managed_agents::ManagedAgentProcess;
|
|
|
|
pub struct AppState {
|
|
pub keys: Mutex<Keys>,
|
|
pub http_client: reqwest::Client,
|
|
pub configured_api_token: Option<String>,
|
|
pub session_token: Mutex<Option<String>>,
|
|
pub managed_agents_store_lock: Mutex<()>,
|
|
pub managed_agent_processes: Mutex<HashMap<String, ManagedAgentProcess>>,
|
|
}
|
|
|
|
pub fn build_app_state() -> AppState {
|
|
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
|
|
// in setup() will replace the ephemeral placeholder with a persisted key.
|
|
let (keys, source) = match std::env::var("SPROUT_PRIVATE_KEY") {
|
|
Ok(nsec) => match Keys::parse(nsec.trim()) {
|
|
Ok(keys) => (keys, "configured"),
|
|
Err(error) => {
|
|
eprintln!("sprout-desktop: invalid SPROUT_PRIVATE_KEY: {error}");
|
|
(Keys::generate(), "ephemeral")
|
|
}
|
|
},
|
|
Err(std::env::VarError::NotUnicode(_)) => {
|
|
eprintln!("sprout-desktop: SPROUT_PRIVATE_KEY contains invalid UTF-8");
|
|
(Keys::generate(), "ephemeral")
|
|
}
|
|
Err(std::env::VarError::NotPresent) => (Keys::generate(), "ephemeral"),
|
|
};
|
|
|
|
if source == "configured" {
|
|
eprintln!(
|
|
"sprout-desktop: configured identity pubkey {}",
|
|
keys.public_key().to_hex()
|
|
);
|
|
}
|
|
|
|
let api_token = match std::env::var("SPROUT_API_TOKEN") {
|
|
Ok(token) if !token.trim().is_empty() => Some(token),
|
|
Ok(_) | Err(std::env::VarError::NotPresent) => None,
|
|
Err(std::env::VarError::NotUnicode(_)) => {
|
|
eprintln!("sprout-desktop: SPROUT_API_TOKEN contains invalid UTF-8");
|
|
None
|
|
}
|
|
};
|
|
|
|
AppState {
|
|
keys: Mutex::new(keys),
|
|
http_client: reqwest::Client::new(),
|
|
configured_api_token: api_token,
|
|
session_token: Mutex::new(None),
|
|
managed_agents_store_lock: Mutex::new(()),
|
|
managed_agent_processes: Mutex::new(HashMap::new()),
|
|
}
|
|
}
|
|
|
|
/// Resolve the user's identity key from the app data directory.
|
|
///
|
|
/// Priority: `SPROUT_PRIVATE_KEY` env var (already handled in `build_app_state`)
|
|
/// → `{app_data_dir}/identity.key` file → generate + save.
|
|
///
|
|
/// Writes use `atomic-write-file` which handles temp file creation, fsync,
|
|
/// atomic rename, and directory sync — no partial or corrupt files on disk.
|
|
pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(), String> {
|
|
// Only skip file-based resolution if the env var was present AND parsed
|
|
// successfully. A malformed env var should fall through to the persisted
|
|
// key rather than leaving the app on an ephemeral identity.
|
|
if let Ok(nsec) = std::env::var("SPROUT_PRIVATE_KEY") {
|
|
if Keys::parse(nsec.trim()).is_ok() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let data_dir = app
|
|
.path()
|
|
.app_data_dir()
|
|
.map_err(|e| format!("app data dir: {e}"))?;
|
|
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
|
|
let key_path = data_dir.join("identity.key");
|
|
|
|
// Try to load an existing key.
|
|
if key_path.exists() {
|
|
match load_key_file(&key_path) {
|
|
Ok(keys) => {
|
|
eprintln!(
|
|
"sprout-desktop: persisted identity pubkey {}",
|
|
keys.public_key().to_hex()
|
|
);
|
|
*state.keys.lock().map_err(|e| e.to_string())? = keys;
|
|
return Ok(());
|
|
}
|
|
Err(error) => {
|
|
// Corrupted — quarantine with a timestamp so prior backups
|
|
// are never overwritten.
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
let bad_name = format!("identity.key.bad.{ts}");
|
|
eprintln!(
|
|
"sprout-desktop: corrupt identity.key ({error}), quarantining to {bad_name}"
|
|
);
|
|
let bad_path = data_dir.join(bad_name);
|
|
if std::fs::rename(&key_path, &bad_path).is_err() {
|
|
let _ = std::fs::remove_file(&key_path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// First run (or recovery from corruption): generate and save.
|
|
let keys = Keys::generate();
|
|
save_key_file(&key_path, &keys)?;
|
|
|
|
eprintln!(
|
|
"sprout-desktop: generated and saved identity pubkey {}",
|
|
keys.public_key().to_hex()
|
|
);
|
|
*state.keys.lock().map_err(|e| e.to_string())? = keys;
|
|
Ok(())
|
|
}
|
|
|
|
fn load_key_file(path: &std::path::Path) -> Result<Keys, String> {
|
|
let content = std::fs::read_to_string(path).map_err(|e| format!("read identity.key: {e}"))?;
|
|
let trimmed = content.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("empty identity.key".to_string());
|
|
}
|
|
Keys::parse(trimmed).map_err(|e| format!("parse identity.key: {e}"))
|
|
}
|
|
|
|
/// Atomically write the key to disk. Uses `atomic-write-file` which:
|
|
/// 1. Writes to a temp file in the same directory
|
|
/// 2. Calls fsync on the file
|
|
/// 3. Renames temp → target (atomic on POSIX, best-effort on Windows)
|
|
/// 4. Calls fsync on the parent directory
|
|
///
|
|
/// On Unix, the file is created with mode 0600 (owner read/write only).
|
|
/// On Windows, default ACLs apply — the app data directory is already
|
|
/// per-user, so the key is not world-readable in practice.
|
|
fn save_key_file(path: &std::path::Path, keys: &Keys) -> Result<(), String> {
|
|
use atomic_write_file::AtomicWriteFile;
|
|
|
|
let nsec = keys
|
|
.secret_key()
|
|
.to_bech32()
|
|
.map_err(|e| format!("encode nsec: {e}"))?;
|
|
|
|
let mut file = AtomicWriteFile::open(path)
|
|
.map_err(|e| format!("open identity.key for atomic write: {e}"))?;
|
|
|
|
// Set owner-only permissions before writing the secret.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
|
.map_err(|e| format!("set identity.key permissions: {e}"))?;
|
|
}
|
|
|
|
file.write_all(nsec.as_bytes())
|
|
.map_err(|e| format!("write identity.key: {e}"))?;
|
|
file.commit()
|
|
.map_err(|e| format!("commit identity.key: {e}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn assert_key_eq(a: &Keys, b: &Keys) {
|
|
assert_eq!(a.public_key().to_hex(), b.public_key().to_hex());
|
|
}
|
|
|
|
#[test]
|
|
fn save_and_load_round_trip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("identity.key");
|
|
let keys = Keys::generate();
|
|
|
|
save_key_file(&path, &keys).unwrap();
|
|
let loaded = load_key_file(&path).unwrap();
|
|
assert_key_eq(&keys, &loaded);
|
|
}
|
|
|
|
#[test]
|
|
fn load_rejects_empty_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("identity.key");
|
|
std::fs::write(&path, "").unwrap();
|
|
|
|
assert!(load_key_file(&path).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn load_rejects_corrupt_content() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("identity.key");
|
|
std::fs::write(&path, "not-a-valid-nsec").unwrap();
|
|
|
|
assert!(load_key_file(&path).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn load_missing_file_is_err() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("nonexistent.key");
|
|
|
|
assert!(load_key_file(&path).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn save_creates_file_with_valid_nsec() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("identity.key");
|
|
let keys = Keys::generate();
|
|
|
|
save_key_file(&path, &keys).unwrap();
|
|
|
|
let content = std::fs::read_to_string(&path).unwrap();
|
|
assert!(content.starts_with("nsec1"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn save_creates_file_with_restricted_permissions() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("identity.key");
|
|
let keys = Keys::generate();
|
|
|
|
save_key_file(&path, &keys).unwrap();
|
|
|
|
let perms = std::fs::metadata(&path).unwrap().permissions();
|
|
assert_eq!(perms.mode() & 0o777, 0o600);
|
|
}
|
|
|
|
#[test]
|
|
fn save_overwrites_existing_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("identity.key");
|
|
|
|
let keys1 = Keys::generate();
|
|
save_key_file(&path, &keys1).unwrap();
|
|
|
|
let keys2 = Keys::generate();
|
|
save_key_file(&path, &keys2).unwrap();
|
|
|
|
let loaded = load_key_file(&path).unwrap();
|
|
assert_key_eq(&keys2, &loaded);
|
|
}
|
|
}
|