| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use super::{identity, ui, Ctx}; |
| 7 | | use anyhow::{bail, Context, Result}; |
| 8 | | use oxy_core::identity::Identity; |
| 9 | | use serde_json::Value as Json; |
| 10 | | use spectra_core::daemon::WireEvent; |
| 11 | | use spectra_core::keys::KeyPair; |
| 12 | | use spectra_core::principals::{identity_payload, signed_event, DELEGATION_SCOPE}; |
| 13 | | use spectra_mcp::client::{host_port, Client, Endpoint}; |
| 14 | | use spectra_mcp::tls::Pinned; |
| 15 | | use std::path::{Path, PathBuf}; |
| 16 | |
|
| 17 | |
|
| 18 | | #[derive(Clone, Debug)] |
| 19 | | pub struct ClientCfg { |
| 20 | | pub server: String, |
| 21 | | pub instance: String, |
| 22 | | pub tls_spki: [u8; 32], |
| 23 | | } |
| 24 | |
|
| 25 | | impl ClientCfg { |
| 26 | 3× | pub fn endpoint(&self) -> Result<Endpoint> { |
| 27 | 3× | Endpoint::tls(&self.server, self.tls_spki) |
| 28 | 3× | } |
| 29 | | } |
| 30 | |
|
| 31 | |
|
| 32 | |
|
| 33 | 4× | pub fn path() -> PathBuf { |
| 34 | 4× | if let Some(p) = std::env::var_os("SPECTRA_SERVER").filter(|p| !p.is_empty()) { |
| 35 | 0× | return PathBuf::from(p); |
| 36 | 4× | } |
| 37 | 4× | config_dir().join("client.toml") |
| 38 | 4× | } |
| 39 | |
|
| 40 | 5× | fn config_dir() -> PathBuf { |
| 41 | 5× | let base = std::env::var_os("XDG_CONFIG_HOME") |
| 42 | 5× | .filter(|p| !p.is_empty()) |
| 43 | 5× | .map(PathBuf::from) |
| 44 | 5× | .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) |
| 45 | 5× | .unwrap_or_else(|| PathBuf::from(".")); |
| 46 | 5× | base.join("spectra") |
| 47 | 5× | } |
| 48 | |
|
| 49 | |
|
| 50 | 1× | pub fn load() -> Result<Option<ClientCfg>> { |
| 51 | 1× | let p = path(); |
| 52 | 1× | let text = match std::fs::read_to_string(&p) { |
| 53 | 0× | Ok(t) => t, |
| 54 | 1× | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
| 55 | 0× | Err(e) => return Err(e).with_context(|| format!("reading {}", p.display())), |
| 56 | | }; |
| 57 | 0× | let t: toml::Table = text.parse().with_context(|| format!("reading {}", p.display()))?; |
| 58 | 0× | let get = |k: &str| t.get(k).and_then(|v| v.as_str()).map(String::from).with_context(|| format!("{} has no {k}", p.display())); |
| 59 | 0× | let spki = hex::decode(get("tls_spki")?).ok().and_then(|b| b.try_into().ok()).with_context(|| format!("{}: tls_spki", p.display()))?; |
| 60 | 0× | Ok(Some(ClientCfg { server: get("server")?, instance: get("instance")?, tls_spki: spki })) |
| 61 | 1× | } |
| 62 | |
|
| 63 | 3× | fn save(cfg: &ClientCfg) -> Result<PathBuf> { |
| 64 | 3× | let p = path(); |
| 65 | 3× | if let Some(dir) = p.parent() { |
| 66 | 3× | std::fs::create_dir_all(dir)?; |
| 67 | 0× | } |
| 68 | 3× | std::fs::write( |
| 69 | 3× | &p, |
| 70 | 3× | format!( |
| 71 | | "# 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", |
| 72 | | cfg.server, |
| 73 | | cfg.instance, |
| 74 | 3× | hex::encode(cfg.tls_spki) |
| 75 | | ), |
| 76 | 0× | )?; |
| 77 | 3× | Ok(p) |
| 78 | 3× | } |
| 79 | |
|
| 80 | |
|
| 81 | 4× | fn own(home: &Path, key: &str) -> Option<KeyPair> { |
| 82 | 4× | let keys = home.join("keys"); |
| 83 | 4× | KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))).ok() |
| 84 | 4× | } |
| 85 | |
|
| 86 | 5× | pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option<PathBuf>) -> Result<i32> { |
| 87 | 5× | let server = format!("https://{}", host_port(url)?); |
| 88 | 5× | let home = owner_home.unwrap_or_else(oxy_core::repo::default_home); |
| 89 | 5× | let owner = KeyPair::load_home(&home) |
| 90 | 5× | .with_context(|| format!("the owner connects with their own identity: none usable in {} (or --owner-home)", home.display()))?; |
| 91 | |
|
| 92 | | |
| 93 | 5× | let probe = Pinned::new(None); |
| 94 | 5× | let anon = Client::at(Endpoint::Tls { addr: host_port(url)?, config: probe.client_config()? }, None); |
| 95 | 5× | let r = anon.send("GET", "/api/v1/instance", &[], vec![]).await.with_context(|| format!("reaching {server}"))?; |
| 96 | 5× | if r.status != 200 { |
| 97 | 0× | bail!("{server} answered {} to /api/v1/instance: is it a Spectra?", r.status); |
| 98 | 5× | } |
| 99 | 5× | let answer = r.json()?; |
| 100 | 5× | let (_, seen) = probe.seen().context("the server showed no certificate")?; |
| 101 | 5× | let checked = spectra_mcp::instance::check(&answer, &seen)?; |
| 102 | 5× | let instance_key = checked.instance.hash(); |
| 103 | |
|
| 104 | | |
| 105 | 5× | let recorded = checked.owner.clone(); |
| 106 | 5× | let named = answer.get("named_owner").and_then(Json::as_str).map(String::from); |
| 107 | 5× | match (&recorded, &named) { |
| 108 | 2× | (Some(k), _) if *k != owner.key.to_hex() && own(&home, k).is_none() => { |
| 109 | 0× | bail!("{server} belongs to someone else (owner {})", &k[..12.min(k.len())]); |
| 110 | | } |
| 111 | 3× | (None, Some(k)) if own(&home, k).is_none() => { |
| 112 | 1× | bail!("{server} was set up for {}, which is not an identity in {}", &k[..12.min(k.len())], home.display()); |
| 113 | | } |
| 114 | 0× | (None, None) => bail!("{server} has no owner and names none"), |
| 115 | 4× | _ => {} |
| 116 | | } |
| 117 | |
|
| 118 | | |
| 119 | 4× | if !owner.identity.delegates_to(&instance_key, DELEGATION_SCOPE, spectra_core::ids::now_ms()) { |
| 120 | 1× | let dir = config_dir(); |
| 121 | 1× | std::fs::create_dir_all(&dir)?; |
| 122 | 1× | let file = dir.join(format!("{}-instance.identity", safe(&checked.name))); |
| 123 | 1× | std::fs::write(&file, checked.instance.encode())?; |
| 124 | 1× | println!("{} {} {}", ui::caution(), ui::subject(&server), ui::dim("is not yet yours to trust")); |
| 125 | 1× | println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.short(12))))); |
| 126 | 1× | println!(" {} {}", ui::note(), ui::dim("your identity does not delegate to its instance key; if this is your server, run:")); |
| 127 | 1× | println!(" {}", ui::id(format!("oxy identity delegate add {} --scope changes", file.display()))); |
| 128 | 1× | println!(" {} {}", ui::note(), ui::dim("then run spectra connect again")); |
| 129 | 1× | return Ok(1); |
| 130 | 3× | } |
| 131 | |
|
| 132 | 3× | let cfg = ClientCfg { server: server.clone(), instance: instance_key.to_hex(), tls_spki: seen }; |
| 133 | 3× | let saved = save(&cfg)?; |
| 134 | 3× | let pinned = cfg.endpoint()?; |
| 135 | |
|
| 136 | | |
| 137 | 3× | let mut claimed = false; |
| 138 | 3× | match (&recorded, &named) { |
| 139 | 1× | (None, Some(k)) => { |
| 140 | 1× | let named_pair = own(&home, k).expect("checked above"); |
| 141 | 1× | let mut events = |
| 142 | 1× | vec![WireEvent::from_new(&signed_event(&named_pair, "human", "identity.owner", identity_payload(&named_pair.identity)))]; |
| 143 | 1× | if let Some(e) = identity::rotation(&named_pair.identity, &home)? { |
| 144 | 1× | events.push(WireEvent::from_new(&e)); |
| 145 | 1× | } |
| 146 | 1× | let r = Client::at(pinned.clone(), None).send("POST", "/api/v1/claim", &[], serde_json::to_vec(&events)?).await?; |
| 147 | 1× | if r.status != 200 { |
| 148 | 0× | bail!("{server} refused the claim: {}", problem(&r.body)); |
| 149 | 1× | } |
| 150 | 1× | claimed = true; |
| 151 | | } |
| 152 | 2× | (Some(k), _) if *k != owner.key.to_hex() => { |
| 153 | 0× | let rec_pair = own(&home, k).expect("checked above"); |
| 154 | 0× | let rec_id: Identity = rec_pair.identity.clone(); |
| 155 | 0× | if let Some(e) = identity::rotation(&rec_id, &home)? { |
| 156 | 0× | let body = serde_json::to_vec(&vec![WireEvent::from_new(&e)])?; |
| 157 | 0× | let r = Client::at(pinned.clone(), Some(rec_pair)).send("POST", "/api/v1/ledger/events", &[], body).await?; |
| 158 | 0× | if r.status != 200 { |
| 159 | 0× | bail!("{server} refused the owner's current identity: {}", problem(&r.body)); |
| 160 | 0× | } |
| 161 | 0× | } |
| 162 | | } |
| 163 | 2× | _ => {} |
| 164 | | } |
| 165 | |
|
| 166 | 3× | println!("{} {} {}", ui::done(if claimed { "Claimed" } else { "Connected" }), ui::subject(&checked.name), ui::path(&server)); |
| 167 | 3× | println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12))))); |
| 168 | 3× | println!("{}", ui::kv("instance", format!("{}{}", ui::hash(instance_key.short(12)), ui::dim(" (delegated by you)")))); |
| 169 | 3× | println!( |
| 170 | | "{}", |
| 171 | 3× | ui::kv("certificate", format!("{}{}", ui::hash(hex::encode(&seen[..8])), ui::dim(" (pinned; vouched for by the instance key)"))) |
| 172 | | ); |
| 173 | 3× | println!("{}", ui::kv("config", ui::path(saved.display()))); |
| 174 | 3× | Ok(0) |
| 175 | 5× | } |
| 176 | |
|
| 177 | 1× | fn safe(name: &str) -> String { |
| 178 | 5× | let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect(); |
| 179 | 1× | if s.is_empty() { |
| 180 | 0× | "spectra".into() |
| 181 | | } else { |
| 182 | 1× | s |
| 183 | | } |
| 184 | 1× | } |
| 185 | |
|
| 186 | 0× | fn problem(body: &[u8]) -> String { |
| 187 | 0× | serde_json::from_slice::<Json>(body) |
| 188 | 0× | .ok() |
| 189 | 0× | .and_then(|v| v.get("detail").or_else(|| v.get("title")).and_then(Json::as_str).map(String::from)) |
| 190 | 0× | .unwrap_or_else(|| String::from_utf8_lossy(body).into_owned()) |
| 191 | 0× | } |