OXYGENHUB

Coverage / src/cli/connect.rs 75.5%

111 of 147 lines run, 15 of 56 functions · run #23 on main · run missed

1
//! `spectra connect` and the client configuration (spec §8.6): trust a
2
//! Spectra on another machine because the owner's own identity delegates
3
//! to its instance key and that key vouches for its certificate; pin the
4
//! certificate; claim a server set up with `spectra init --owner`.
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
/// The client configuration: which server, and what to trust there.
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 {
2620×
    pub fn endpoint(&self) -> Result<Endpoint> {
2720×
        Endpoint::tls(&self.server, self.tls_spki)
2820×
    }
29
}
30
31
/// `SPECTRA_SERVER`, else `$XDG_CONFIG_HOME/spectra/client.toml`, else
32
/// `~/.config/spectra/client.toml`.
33131×
pub fn path() -> PathBuf {
34131×
    if let Some(p) = std::env::var_os("SPECTRA_SERVER").filter(|p| !p.is_empty()) {
350×
        return PathBuf::from(p);
36131×
    }
37131×
    config_dir().join("client.toml")
38131×
}
39
40134×
fn config_dir() -> PathBuf {
41134×
    let base = std::env::var_os("XDG_CONFIG_HOME")
42134×
        .filter(|p| !p.is_empty())
43134×
        .map(PathBuf::from)
44134×
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
45134×
        .unwrap_or_else(|| PathBuf::from("."));
46134×
    base.join("spectra")
47134×
}
48
49
/// The client configuration, if this machine uses a Spectra elsewhere.
50125×
pub fn load() -> Result<Option<ClientCfg>> {
51125×
    let p = path();
52125×
    let text = match std::fs::read_to_string(&p) {
5317×
        Ok(t) => t,
54108×
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
550×
        Err(e) => return Err(e).with_context(|| format!("reading {}", p.display())),
56
    };
5717×
    let t: toml::Table = text.parse().with_context(|| format!("reading {}", p.display()))?;
5851×
    let get = |k: &str| t.get(k).and_then(|v| v.as_str()).map(String::from).with_context(|| format!("{} has no {k}", p.display()));
5917×
    let spki = hex::decode(get("tls_spki")?).ok().and_then(|b| b.try_into().ok()).with_context(|| format!("{}: tls_spki", p.display()))?;
6017×
    Ok(Some(ClientCfg { server: get("server")?, instance: get("instance")?, tls_spki: spki }))
61125×
}
62
635×
fn save(cfg: &ClientCfg) -> Result<PathBuf> {
645×
    let p = path();
655×
    if let Some(dir) = p.parent() {
665×
        std::fs::create_dir_all(dir)?;
670×
    }
685×
    std::fs::write(
695×
        &p,
705×
        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,
745×
            hex::encode(cfg.tls_spki)
75
        ),
760×
    )?;
775×
    Ok(p)
785×
}
79
80
/// The key pair of the identity `key` in the owner's home, if it is theirs.
8110×
fn own(home: &Path, key: &str) -> Option<KeyPair> {
8210×
    let keys = home.join("keys");
8310×
    KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))).ok()
8410×
}
85
869×
pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option<PathBuf>) -> Result<i32> {
879×
    let server = format!("https://{}", host_port(url)?);
889×
    let home = owner_home.unwrap_or_else(oxy_core::repo::default_home);
899×
    let owner = KeyPair::load_home(&home)
909×
        .with_context(|| format!("the owner connects with their own identity: none usable in {} (or --owner-home)", home.display()))?;
91
92
    // Who is there, before trusting anything.
939×
    let probe = Pinned::new(None);
949×
    let anon = Client::at(Endpoint::Tls { addr: host_port(url)?, config: probe.client_config()? }, None);
959×
    let r = anon.send("GET", "/api/v1/instance", &[], vec![]).await.with_context(|| format!("reaching {server}"))?;
969×
    if r.status != 200 {
970×
        bail!("{server} answered {} to /api/v1/instance: is it a Spectra?", r.status);
989×
    }
999×
    let answer = r.json()?;
1009×
    let (_, seen) = probe.seen().context("the server showed no certificate")?;
1019×
    let checked = spectra_mcp::instance::check(&answer, &seen)?;
1029×
    let instance_key = checked.instance.hash();
103
104
    // Whose it is, or whose it is waiting for.
1059×
    let recorded = checked.owner.clone();
1069×
    let named = answer.get("named_owner").and_then(Json::as_str).map(String::from);
1079×
    match (&recorded, &named) {
1082×
        (Some(k), _) if *k != owner.key.to_hex() && own(&home, k).is_none() => {
1090×
            bail!("{server} belongs to someone else (owner {})", &k[..12.min(k.len())]);
110
        }
1117×
        (None, Some(k)) if own(&home, k).is_none() => {
1121×
            bail!("{server} was set up for {}, which is not an identity in {}", &k[..12.min(k.len())], home.display());
113
        }
1140×
        (None, None) => bail!("{server} has no owner and names none"),
1158×
        _ => {}
116
    }
117
118
    // Trust: the owner's own identity delegates to that instance key now.
1198×
    if !owner.identity.delegates_to(&instance_key, DELEGATION_SCOPE, spectra_core::ids::now_ms()) {
1203×
        let dir = config_dir();
1213×
        std::fs::create_dir_all(&dir)?;
1223×
        let file = dir.join(format!("{}-instance.identity", safe(&checked.name)));
1233×
        std::fs::write(&file, checked.instance.encode())?;
1243×
        println!("{} {} {}", ui::caution(), ui::subject(&server), ui::dim("is not yet yours to trust"));
1253×
        println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.short(12)))));
1263×
        println!("  {} {}", ui::note(), ui::dim("your identity does not delegate to its instance key; if this is your server, run:"));
1273×
        println!("    {}", ui::id(format!("oxy identity delegate add {} --scope changes", file.display())));
1283×
        println!("  {} {}", ui::note(), ui::dim("then run spectra connect again"));
1293×
        return Ok(1);
1305×
    }
131
1325×
    let cfg = ClientCfg { server: server.clone(), instance: instance_key.to_hex(), tls_spki: seen };
1335×
    let saved = save(&cfg)?;
1345×
    let pinned = cfg.endpoint()?;
135
136
    // Claim it, or bring it up to the owner's current identity.
1375×
    let mut claimed = false;
1385×
    match (&recorded, &named) {
1393×
        (None, Some(k)) => {
1403×
            let named_pair = own(&home, k).expect("checked above");
1413×
            let mut events =
1423×
                vec![WireEvent::from_new(&signed_event(&named_pair, "human", "identity.owner", identity_payload(&named_pair.identity)))];
1433×
            if let Some(e) = identity::rotation(&named_pair.identity, &home)? {
1443×
                events.push(WireEvent::from_new(&e));
1453×
            }
1463×
            let r = Client::at(pinned.clone(), None).send("POST", "/api/v1/claim", &[], serde_json::to_vec(&events)?).await?;
1473×
            if r.status != 200 {
1480×
                bail!("{server} refused the claim: {}", problem(&r.body));
1493×
            }
1503×
            claimed = true;
151
        }
1522×
        (Some(k), _) if *k != owner.key.to_hex() => {
1530×
            let rec_pair = own(&home, k).expect("checked above");
1540×
            let rec_id: Identity = rec_pair.identity.clone();
1550×
            if let Some(e) = identity::rotation(&rec_id, &home)? {
1560×
                let body = serde_json::to_vec(&vec![WireEvent::from_new(&e)])?;
1570×
                let r = Client::at(pinned.clone(), Some(rec_pair)).send("POST", "/api/v1/ledger/events", &[], body).await?;
1580×
                if r.status != 200 {
1590×
                    bail!("{server} refused the owner's current identity: {}", problem(&r.body));
1600×
                }
1610×
            }
162
        }
1632×
        _ => {}
164
    }
165
1665×
    println!("{} {}  {}", ui::done(if claimed { "Claimed" } else { "Connected" }), ui::subject(&checked.name), ui::path(&server));
1675×
    println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12)))));
1685×
    println!("{}", ui::kv("instance", format!("{}{}", ui::hash(instance_key.short(12)), ui::dim(" (delegated by you)"))));
1695×
    println!(
170
        "{}",
1715×
        ui::kv("certificate", format!("{}{}", ui::hash(hex::encode(&seen[..8])), ui::dim(" (pinned; vouched for by the instance key)")))
172
    );
1735×
    println!("{}", ui::kv("config", ui::path(saved.display())));
1745×
    Ok(0)
1759×
}
176
1773×
fn safe(name: &str) -> String {
17815×
    let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect();
1793×
    if s.is_empty() {
1800×
        "spectra".into()
181
    } else {
1823×
        s
183
    }
1843×
}
185
1860×
fn problem(body: &[u8]) -> String {
1870×
    serde_json::from_slice::<Json>(body)
1880×
        .ok()
1890×
        .and_then(|v| v.get("detail").or_else(|| v.get("title")).and_then(Json::as_str).map(String::from))
1900×
        .unwrap_or_else(|| String::from_utf8_lossy(body).into_owned())
1910×
}