//! `spectra connect` and the client configuration (spec §8.6): trust a //! Spectra on another machine because the owner's own identity delegates //! to its instance key and that key vouches for its certificate; pin the //! certificate; claim a server set up with `spectra init --owner`. use super::{identity, ui, Ctx}; use anyhow::{bail, Context, Result}; use oxy_core::identity::Identity; use serde_json::Value as Json; use spectra_core::daemon::WireEvent; use spectra_core::keys::KeyPair; use spectra_core::principals::{identity_payload, signed_event, DELEGATION_SCOPE}; use spectra_mcp::client::{host_port, Client, Endpoint}; use spectra_mcp::tls::Pinned; use std::path::{Path, PathBuf}; /// The client configuration: which server, and what to trust there. #[derive(Clone, Debug)] pub struct ClientCfg { pub server: String, pub instance: String, pub tls_spki: [u8; 32], } impl ClientCfg { pub fn endpoint(&self) -> Result { Endpoint::tls(&self.server, self.tls_spki) } } /// `SPECTRA_SERVER`, else `$XDG_CONFIG_HOME/spectra/client.toml`, else /// `~/.config/spectra/client.toml`. pub fn path() -> PathBuf { if let Some(p) = std::env::var_os("SPECTRA_SERVER").filter(|p| !p.is_empty()) { return PathBuf::from(p); } config_dir().join("client.toml") } fn config_dir() -> PathBuf { let base = std::env::var_os("XDG_CONFIG_HOME") .filter(|p| !p.is_empty()) .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) .unwrap_or_else(|| PathBuf::from(".")); base.join("spectra") } /// The client configuration, if this machine uses a Spectra elsewhere. pub fn load() -> Result> { let p = path(); let text = match std::fs::read_to_string(&p) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e).with_context(|| format!("reading {}", p.display())), }; let t: toml::Table = text.parse().with_context(|| format!("reading {}", p.display()))?; let get = |k: &str| t.get(k).and_then(|v| v.as_str()).map(String::from).with_context(|| format!("{} has no {k}", p.display())); let spki = hex::decode(get("tls_spki")?).ok().and_then(|b| b.try_into().ok()).with_context(|| format!("{}: tls_spki", p.display()))?; Ok(Some(ClientCfg { server: get("server")?, instance: get("instance")?, tls_spki: spki })) } fn save(cfg: &ClientCfg) -> Result { let p = path(); if let Some(dir) = p.parent() { std::fs::create_dir_all(dir)?; } std::fs::write( &p, format!( "# Spectra on another machine (spec §8.6), written by `spectra connect`.\n\nserver = \"{}\"\ninstance = \"{}\" # the instance key, delegated by the owner\ntls_spki = \"{}\" # the certificate the instance key vouches for\n", cfg.server, cfg.instance, hex::encode(cfg.tls_spki) ), )?; Ok(p) } /// The key pair of the identity `key` in the owner's home, if it is theirs. fn own(home: &Path, key: &str) -> Option { let keys = home.join("keys"); KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))).ok() } pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option) -> Result { let server = format!("https://{}", host_port(url)?); let home = owner_home.unwrap_or_else(oxy_core::repo::default_home); let owner = KeyPair::load_home(&home) .with_context(|| format!("the owner connects with their own identity: none usable in {} (or --owner-home)", home.display()))?; // Who is there, before trusting anything. let probe = Pinned::new(None); let anon = Client::at(Endpoint::Tls { addr: host_port(url)?, config: probe.client_config()? }, None); let r = anon.send("GET", "/api/v1/instance", &[], vec![]).await.with_context(|| format!("reaching {server}"))?; if r.status != 200 { bail!("{server} answered {} to /api/v1/instance: is it a Spectra?", r.status); } let answer = r.json()?; let (_, seen) = probe.seen().context("the server showed no certificate")?; let checked = spectra_mcp::instance::check(&answer, &seen)?; let instance_key = checked.instance.hash(); // Whose it is, or whose it is waiting for. let recorded = checked.owner.clone(); let named = answer.get("named_owner").and_then(Json::as_str).map(String::from); match (&recorded, &named) { (Some(k), _) if *k != owner.key.to_hex() && own(&home, k).is_none() => { bail!("{server} belongs to someone else (owner {})", &k[..12.min(k.len())]); } (None, Some(k)) if own(&home, k).is_none() => { bail!("{server} was set up for {}, which is not an identity in {}", &k[..12.min(k.len())], home.display()); } (None, None) => bail!("{server} has no owner and names none"), _ => {} } // Trust: the owner's own identity delegates to that instance key now. if !owner.identity.delegates_to(&instance_key, DELEGATION_SCOPE, spectra_core::ids::now_ms()) { let dir = config_dir(); std::fs::create_dir_all(&dir)?; let file = dir.join(format!("{}-instance.identity", safe(&checked.name))); std::fs::write(&file, checked.instance.encode())?; println!("{} {} {}", ui::caution(), ui::subject(&server), ui::dim("is not yet yours to trust")); println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.short(12))))); println!(" {} {}", ui::note(), ui::dim("your identity does not delegate to its instance key; if this is your server, run:")); println!(" {}", ui::id(format!("oxy identity delegate add {} --scope changes", file.display()))); println!(" {} {}", ui::note(), ui::dim("then run spectra connect again")); return Ok(1); } let cfg = ClientCfg { server: server.clone(), instance: instance_key.to_hex(), tls_spki: seen }; let saved = save(&cfg)?; let pinned = cfg.endpoint()?; // Claim it, or bring it up to the owner's current identity. let mut claimed = false; match (&recorded, &named) { (None, Some(k)) => { let named_pair = own(&home, k).expect("checked above"); let mut events = vec![WireEvent::from_new(&signed_event(&named_pair, "human", "identity.owner", identity_payload(&named_pair.identity)))]; if let Some(e) = identity::rotation(&named_pair.identity, &home)? { events.push(WireEvent::from_new(&e)); } let r = Client::at(pinned.clone(), None).send("POST", "/api/v1/claim", &[], serde_json::to_vec(&events)?).await?; if r.status != 200 { bail!("{server} refused the claim: {}", problem(&r.body)); } claimed = true; } (Some(k), _) if *k != owner.key.to_hex() => { let rec_pair = own(&home, k).expect("checked above"); let rec_id: Identity = rec_pair.identity.clone(); if let Some(e) = identity::rotation(&rec_id, &home)? { let body = serde_json::to_vec(&vec![WireEvent::from_new(&e)])?; let r = Client::at(pinned.clone(), Some(rec_pair)).send("POST", "/api/v1/ledger/events", &[], body).await?; if r.status != 200 { bail!("{server} refused the owner's current identity: {}", problem(&r.body)); } } } _ => {} } println!("{} {} {}", ui::done(if claimed { "Claimed" } else { "Connected" }), ui::subject(&checked.name), ui::path(&server)); println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12))))); println!("{}", ui::kv("instance", format!("{}{}", ui::hash(instance_key.short(12)), ui::dim(" (delegated by you)")))); println!( "{}", ui::kv("certificate", format!("{}{}", ui::hash(hex::encode(&seen[..8])), ui::dim(" (pinned; vouched for by the instance key)"))) ); println!("{}", ui::kv("config", ui::path(saved.display()))); Ok(0) } fn safe(name: &str) -> String { let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect(); if s.is_empty() { "spectra".into() } else { s } } fn problem(body: &[u8]) -> String { serde_json::from_slice::(body) .ok() .and_then(|v| v.get("detail").or_else(|| v.get("title")).and_then(Json::as_str).map(String::from)) .unwrap_or_else(|| String::from_utf8_lossy(body).into_owned()) }