//! `spectra connect` (spec ยง8.6): a workstation trusts a server once the //! owner confirms its instance key and delegates to it, and that key //! vouches for its certificate; it claims a server set up with `init //! --owner`, even though delegating changed the owner's KeyId; a new //! certificate is refused until connect runs again; someone else's server //! is refused. mod common; use common::{hermetic, run, text, Serving}; use spectra_core::keys::KeyPair; use spectra_mcp::client::{Client, Endpoint}; use spectra_mcp::tls::Pinned; use std::path::Path; /// A server in `s` set up for the owner whose home is `owner_home`. fn server_for(s: &Path, owner_home: &Path) -> (Serving, u16) { let owner = KeyPair::load_home(owner_home).unwrap(); let o = run(s, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]); assert!(o.status.success(), "{}", text(&o)); Serving::start_tls(s) } async fn instance_owner(port: u16) -> serde_json::Value { let p = Pinned::new(None); let c = Client::at(Endpoint::Tls { addr: format!("127.0.0.1:{port}"), config: p.client_config().unwrap() }, None); c.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap()["owner"].clone() } #[tokio::test] async fn the_owner_connects_delegates_and_claims() { let (sd, cd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); let (s, c) = (sd.path(), cd.path()); KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap(); let (srv, port) = server_for(s, &c.join("owner")); let url = format!("https://127.0.0.1:{port}"); // First contact, not at a terminal and not confirmed: nothing trusted. let o = run(c, &["connect", &url]); assert!(!o.status.success(), "{}", text(&o)); assert!(text(&o).contains("--yes"), "{}", text(&o)); assert!(!c.join("owner/client.toml").exists(), "nothing trusted yet"); // A key that does not match what `init` printed is refused. let o = run(c, &["connect", &url, "--expect", "00ff"]); assert!(!o.status.success() && text(&o).contains("does not start with"), "{}", text(&o)); // The owner checks the instance key against what `init` printed (here, // by --expect) and connects: their identity now delegates to it, under // a new KeyId ... let instance = KeyPair::instance(&s.join("data"), "earth").unwrap(); let before = KeyPair::load_home(&c.join("owner")).unwrap(); // `probe` says what the server is, without trusting it. let p: serde_json::Value = serde_json::from_slice(&run(c, &["probe", &url, "--json"]).stdout).unwrap(); assert_eq!(p["instance"], instance.key.to_hex()); assert_eq!(p["waiting_for"], before.key.to_hex()); assert_eq!(p["yours"], true, "{p}"); assert!(!c.join("owner/client.toml").exists(), "probing trusts nothing"); // ... and the server is trusted, pinned, and claimed as theirs. let o = run(c, &["connect", &url, "--expect", &instance.key.short(16)]); assert!(o.status.success(), "{}", text(&o)); assert!(text(&o).contains("Claimed"), "{}", text(&o)); let current = KeyPair::load_home(&c.join("owner")).unwrap(); assert_ne!(current.key, before.key); assert!(current.identity.delegates_to(&instance.key, 1)); let cfg = std::fs::read_to_string(c.join("owner/client.toml")).unwrap(); assert!(cfg.contains(&instance.key.to_hex()) && cfg.contains("tls_spki"), "{cfg}"); assert_eq!(instance_owner(port).await, current.key.to_hex(), "recorded at the owner's current identity"); // Again: nothing to claim, still trusted. let o = run(c, &["connect", &url]); assert!(o.status.success(), "{}", text(&o)); assert!(text(&o).contains("Connected"), "{}", text(&o)); srv.stop(); // A new certificate (the old one lost) is refused on the old pin ... std::fs::remove_file(s.join("data/keys/tls.crt")).unwrap(); std::fs::remove_file(s.join("data/keys/tls.key")).unwrap(); let (srv, port) = Serving::start_tls(s); let url = format!("https://127.0.0.1:{port}"); let cfg = std::fs::read_to_string(c.join("owner/client.toml")).unwrap(); let old_pin: [u8; 32] = hex::decode(cfg.lines().find(|l| l.starts_with("tls_spki")).unwrap().split('"').nth(1).unwrap()).unwrap().try_into().unwrap(); let pinned = Client::at(Endpoint::tls(&url, old_pin).unwrap(), None); assert!(pinned.send("GET", "/api/v1/instance", &[], vec![]).await.is_err()); // ... until connect checks that the same instance key vouches for it. let o = run(c, &["connect", &url]); assert!(o.status.success(), "{}", text(&o)); let cfg2 = std::fs::read_to_string(c.join("owner/client.toml")).unwrap(); assert_ne!(cfg, cfg2, "re-pinned"); srv.stop(); let o = hermetic(s).args(["admin", "verify"]).output().unwrap(); assert!(o.status.success(), "{}", text(&o)); } #[test] fn someone_elses_server_is_refused() { let (sd, cd, md) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); let (s, c, m) = (sd.path(), cd.path(), md.path()); KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap(); KeyPair::create_home(&m.join("owner"), "Mallory", "").unwrap(); let (srv, port) = server_for(s, &m.join("owner")); let o = run(c, &["connect", &format!("https://127.0.0.1:{port}")]); assert!(!o.status.success()); assert!(text(&o).contains("was set up for"), "{}", text(&o)); srv.stop(); }