spectra connect: trust a server through the owner's delegation, pin it, claim it (#21) eca5af92279b
A workstation trusts a Spectra elsewhere only when the owner's identity delegates to its instance key and that key vouches for its certificate. The claim carries the named identity and the rotation to the owner's current one, since delegating to the instance changes the owner's KeyId. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key bc27dd96080c
- When
- · 2026-11-03 13:29 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
eca5af92279b8382fc9aebe2997e19494c1c7b58a1855af8eef9eb75ca4cf8cf- Depends on
- 09752034b5a8 258bb3824813 36537112fcac 571572f5fb2c 77df591d5a2f 8dc6df8c4d4e b576d05cf862 d17cbc7069ce eb675dfa5e18
- Depended on by
- c7161ec1a607 b044d90930d9 98a0fb9f0948 67bfa06a894f
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 28 atoms, 2 tree operations, 10 files
Browse the tree at this change
added src/cli/connect.rs +191 −0
| 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 { |
|
| 26 | + | pub fn endpoint(&self) -> Result<Endpoint> { |
|
| 27 | + | Endpoint::tls(&self.server, self.tls_spki) |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /// `SPECTRA_SERVER`, else `$XDG_CONFIG_HOME/spectra/client.toml`, else |
|
| 32 | + | /// `~/.config/spectra/client.toml`. |
|
| 33 | + | pub fn path() -> PathBuf { |
|
| 34 | + | if let Some(p) = std::env::var_os("SPECTRA_SERVER").filter(|p| !p.is_empty()) { |
|
| 35 | + | return PathBuf::from(p); |
|
| 36 | + | } |
|
| 37 | + | config_dir().join("client.toml") |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | fn config_dir() -> PathBuf { |
|
| 41 | + | let base = std::env::var_os("XDG_CONFIG_HOME") |
|
| 42 | + | .filter(|p| !p.is_empty()) |
|
| 43 | + | .map(PathBuf::from) |
|
| 44 | + | .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) |
|
| 45 | + | .unwrap_or_else(|| PathBuf::from(".")); |
|
| 46 | + | base.join("spectra") |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// The client configuration, if this machine uses a Spectra elsewhere. |
|
| 50 | + | pub fn load() -> Result<Option<ClientCfg>> { |
|
| 51 | + | let p = path(); |
|
| 52 | + | let text = match std::fs::read_to_string(&p) { |
|
| 53 | + | Ok(t) => t, |
|
| 54 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
|
| 55 | + | Err(e) => return Err(e).with_context(|| format!("reading {}", p.display())), |
|
| 56 | + | }; |
|
| 57 | + | let t: toml::Table = text.parse().with_context(|| format!("reading {}", p.display()))?; |
|
| 58 | + | 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 | + | let spki = hex::decode(get("tls_spki")?).ok().and_then(|b| b.try_into().ok()).with_context(|| format!("{}: tls_spki", p.display()))?; |
|
| 60 | + | Ok(Some(ClientCfg { server: get("server")?, instance: get("instance")?, tls_spki: spki })) |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | fn save(cfg: &ClientCfg) -> Result<PathBuf> { |
|
| 64 | + | let p = path(); |
|
| 65 | + | if let Some(dir) = p.parent() { |
|
| 66 | + | std::fs::create_dir_all(dir)?; |
|
| 67 | + | } |
|
| 68 | + | std::fs::write( |
|
| 69 | + | &p, |
|
| 70 | + | 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 | + | hex::encode(cfg.tls_spki) |
|
| 75 | + | ), |
|
| 76 | + | )?; |
|
| 77 | + | Ok(p) |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | /// The key pair of the identity `key` in the owner's home, if it is theirs. |
|
| 81 | + | fn own(home: &Path, key: &str) -> Option<KeyPair> { |
|
| 82 | + | let keys = home.join("keys"); |
|
| 83 | + | KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))).ok() |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option<PathBuf>) -> Result<i32> { |
|
| 87 | + | let server = format!("https://{}", host_port(url)?); |
|
| 88 | + | let home = owner_home.unwrap_or_else(oxy_core::repo::default_home); |
|
| 89 | + | let owner = KeyPair::load_home(&home) |
|
| 90 | + | .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. |
|
| 93 | + | let probe = Pinned::new(None); |
|
| 94 | + | let anon = Client::at(Endpoint::Tls { addr: host_port(url)?, config: probe.client_config()? }, None); |
|
| 95 | + | let r = anon.send("GET", "/api/v1/instance", &[], vec![]).await.with_context(|| format!("reaching {server}"))?; |
|
| 96 | + | if r.status != 200 { |
|
| 97 | + | bail!("{server} answered {} to /api/v1/instance: is it a Spectra?", r.status); |
|
| 98 | + | } |
|
| 99 | + | let answer = r.json()?; |
|
| 100 | + | let (_, seen) = probe.seen().context("the server showed no certificate")?; |
|
| 101 | + | let checked = spectra_mcp::instance::check(&answer, &seen)?; |
|
| 102 | + | let instance_key = checked.instance.hash(); |
|
| 103 | + | ||
| 104 | + | // Whose it is, or whose it is waiting for. |
|
| 105 | + | let recorded = checked.owner.clone(); |
|
| 106 | + | let named = answer.get("named_owner").and_then(Json::as_str).map(String::from); |
|
| 107 | + | match (&recorded, &named) { |
|
| 108 | + | (Some(k), _) if *k != owner.key.to_hex() && own(&home, k).is_none() => { |
|
| 109 | + | bail!("{server} belongs to someone else (owner {})", &k[..12.min(k.len())]); |
|
| 110 | + | } |
|
| 111 | + | (None, Some(k)) if own(&home, k).is_none() => { |
|
| 112 | + | bail!("{server} was set up for {}, which is not an identity in {}", &k[..12.min(k.len())], home.display()); |
|
| 113 | + | } |
|
| 114 | + | (None, None) => bail!("{server} has no owner and names none"), |
|
| 115 | + | _ => {} |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | // Trust: the owner's own identity delegates to that instance key now. |
|
| 119 | + | if !owner.identity.delegates_to(&instance_key, DELEGATION_SCOPE, spectra_core::ids::now_ms()) { |
|
| 120 | + | let dir = config_dir(); |
|
| 121 | + | std::fs::create_dir_all(&dir)?; |
|
| 122 | + | let file = dir.join(format!("{}-instance.identity", safe(&checked.name))); |
|
| 123 | + | std::fs::write(&file, checked.instance.encode())?; |
|
| 124 | + | println!("{} {} {}", ui::caution(), ui::subject(&server), ui::dim("is not yet yours to trust")); |
|
| 125 | + | println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.short(12))))); |
|
| 126 | + | println!(" {} {}", ui::note(), ui::dim("your identity does not delegate to its instance key; if this is your server, run:")); |
|
| 127 | + | println!(" {}", ui::id(format!("oxy identity delegate add {} --scope changes", file.display()))); |
|
| 128 | + | println!(" {} {}", ui::note(), ui::dim("then run spectra connect again")); |
|
| 129 | + | return Ok(1); |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | let cfg = ClientCfg { server: server.clone(), instance: instance_key.to_hex(), tls_spki: seen }; |
|
| 133 | + | let saved = save(&cfg)?; |
|
| 134 | + | let pinned = cfg.endpoint()?; |
|
| 135 | + | ||
| 136 | + | // Claim it, or bring it up to the owner's current identity. |
|
| 137 | + | let mut claimed = false; |
|
| 138 | + | match (&recorded, &named) { |
|
| 139 | + | (None, Some(k)) => { |
|
| 140 | + | let named_pair = own(&home, k).expect("checked above"); |
|
| 141 | + | let mut events = |
|
| 142 | + | vec![WireEvent::from_new(&signed_event(&named_pair, "human", "identity.owner", identity_payload(&named_pair.identity)))]; |
|
| 143 | + | if let Some(e) = identity::rotation(&named_pair.identity, &home)? { |
|
| 144 | + | events.push(WireEvent::from_new(&e)); |
|
| 145 | + | } |
|
| 146 | + | let r = Client::at(pinned.clone(), None).send("POST", "/api/v1/claim", &[], serde_json::to_vec(&events)?).await?; |
|
| 147 | + | if r.status != 200 { |
|
| 148 | + | bail!("{server} refused the claim: {}", problem(&r.body)); |
|
| 149 | + | } |
|
| 150 | + | claimed = true; |
|
| 151 | + | } |
|
| 152 | + | (Some(k), _) if *k != owner.key.to_hex() => { |
|
| 153 | + | let rec_pair = own(&home, k).expect("checked above"); |
|
| 154 | + | let rec_id: Identity = rec_pair.identity.clone(); |
|
| 155 | + | if let Some(e) = identity::rotation(&rec_id, &home)? { |
|
| 156 | + | let body = serde_json::to_vec(&vec![WireEvent::from_new(&e)])?; |
|
| 157 | + | let r = Client::at(pinned.clone(), Some(rec_pair)).send("POST", "/api/v1/ledger/events", &[], body).await?; |
|
| 158 | + | if r.status != 200 { |
|
| 159 | + | bail!("{server} refused the owner's current identity: {}", problem(&r.body)); |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | } |
|
| 163 | + | _ => {} |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | println!("{} {} {}", ui::done(if claimed { "Claimed" } else { "Connected" }), ui::subject(&checked.name), ui::path(&server)); |
|
| 167 | + | println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12))))); |
|
| 168 | + | println!("{}", ui::kv("instance", format!("{}{}", ui::hash(instance_key.short(12)), ui::dim(" (delegated by you)")))); |
|
| 169 | + | println!( |
|
| 170 | + | "{}", |
|
| 171 | + | ui::kv("certificate", format!("{}{}", ui::hash(hex::encode(&seen[..8])), ui::dim(" (pinned; vouched for by the instance key)"))) |
|
| 172 | + | ); |
|
| 173 | + | println!("{}", ui::kv("config", ui::path(saved.display()))); |
|
| 174 | + | Ok(0) |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | fn safe(name: &str) -> String { |
|
| 178 | + | let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect(); |
|
| 179 | + | if s.is_empty() { |
|
| 180 | + | "spectra".into() |
|
| 181 | + | } else { |
|
| 182 | + | s |
|
| 183 | + | } |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | fn problem(body: &[u8]) -> String { |
|
| 187 | + | serde_json::from_slice::<Json>(body) |
|
| 188 | + | .ok() |
|
| 189 | + | .and_then(|v| v.get("detail").or_else(|| v.get("title")).and_then(Json::as_str).map(String::from)) |
|
| 190 | + | .unwrap_or_else(|| String::from_utf8_lossy(body).into_owned()) |
|
| 191 | + | } |
added tests/connect.rs +97 −0
| 1 | + | //! `spectra connect` (spec §8.6, spectra#21): a workstation trusts a server |
|
| 2 | + | //! because the owner delegates to its instance key and that key vouches for |
|
| 3 | + | //! its certificate; it claims a server set up with `init --owner`, even |
|
| 4 | + | //! though delegating changed the owner's KeyId; a new certificate is |
|
| 5 | + | //! refused until connect runs again; someone else's server is refused. |
|
| 6 | + | ||
| 7 | + | mod common; |
|
| 8 | + | ||
| 9 | + | use common::{delegate, hermetic, run, text, Serving}; |
|
| 10 | + | use spectra_core::keys::KeyPair; |
|
| 11 | + | use spectra_mcp::client::{Client, Endpoint}; |
|
| 12 | + | use spectra_mcp::tls::Pinned; |
|
| 13 | + | use std::path::Path; |
|
| 14 | + | ||
| 15 | + | /// A server in `s` set up for the owner whose home is `owner_home`. |
|
| 16 | + | fn server_for(s: &Path, owner_home: &Path) -> (Serving, u16) { |
|
| 17 | + | let owner = KeyPair::load_home(owner_home).unwrap(); |
|
| 18 | + | let o = run(s, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]); |
|
| 19 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 20 | + | Serving::start_tls(s) |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | async fn instance_owner(port: u16) -> serde_json::Value { |
|
| 24 | + | let p = Pinned::new(None); |
|
| 25 | + | let c = Client::at(Endpoint::Tls { addr: format!("127.0.0.1:{port}"), config: p.client_config().unwrap() }, None); |
|
| 26 | + | c.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap()["owner"].clone() |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | #[tokio::test] |
|
| 30 | + | async fn the_owner_connects_delegates_and_claims() { |
|
| 31 | + | let (sd, cd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); |
|
| 32 | + | let (s, c) = (sd.path(), cd.path()); |
|
| 33 | + | KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap(); |
|
| 34 | + | let (srv, port) = server_for(s, &c.join("owner")); |
|
| 35 | + | let url = format!("https://127.0.0.1:{port}"); |
|
| 36 | + | ||
| 37 | + | // First contact: not yet delegated, so not trusted; told what to run. |
|
| 38 | + | let o = run(c, &["connect", &url]); |
|
| 39 | + | assert_eq!(o.status.code(), Some(1), "{}", text(&o)); |
|
| 40 | + | assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o)); |
|
| 41 | + | let saved = c.join(".config/spectra/earth-instance.identity"); |
|
| 42 | + | assert!(saved.exists(), "the instance's public identity is saved to delegate to"); |
|
| 43 | + | assert!(!c.join(".config/spectra/client.toml").exists(), "nothing trusted yet"); |
|
| 44 | + | ||
| 45 | + | // The owner delegates (which gives their identity a new KeyId) ... |
|
| 46 | + | let instance = KeyPair::instance(&s.join("data"), "earth").unwrap(); |
|
| 47 | + | assert_eq!(oxy_core::identity::Identity::decode(&std::fs::read(&saved).unwrap()).unwrap().hash(), instance.key); |
|
| 48 | + | delegate(&c.join("owner"), &instance); |
|
| 49 | + | let current = KeyPair::load_home(&c.join("owner")).unwrap(); |
|
| 50 | + | ||
| 51 | + | // ... and connects again: trusted, pinned, and claimed as theirs. |
|
| 52 | + | let o = run(c, &["connect", &url]); |
|
| 53 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 54 | + | assert!(text(&o).contains("Claimed"), "{}", text(&o)); |
|
| 55 | + | let cfg = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap(); |
|
| 56 | + | assert!(cfg.contains(&instance.key.to_hex()) && cfg.contains("tls_spki"), "{cfg}"); |
|
| 57 | + | assert_eq!(instance_owner(port).await, current.key.to_hex(), "recorded at the owner's current identity"); |
|
| 58 | + | ||
| 59 | + | // Again: nothing to claim, still trusted. |
|
| 60 | + | let o = run(c, &["connect", &url]); |
|
| 61 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 62 | + | assert!(text(&o).contains("Connected"), "{}", text(&o)); |
|
| 63 | + | srv.stop(); |
|
| 64 | + | ||
| 65 | + | // A new certificate (the old one lost) is refused on the old pin ... |
|
| 66 | + | std::fs::remove_file(s.join("data/keys/tls.crt")).unwrap(); |
|
| 67 | + | std::fs::remove_file(s.join("data/keys/tls.key")).unwrap(); |
|
| 68 | + | let (srv, port) = Serving::start_tls(s); |
|
| 69 | + | let url = format!("https://127.0.0.1:{port}"); |
|
| 70 | + | let cfg = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap(); |
|
| 71 | + | let old_pin: [u8; 32] = |
|
| 72 | + | hex::decode(cfg.lines().find(|l| l.starts_with("tls_spki")).unwrap().split('"').nth(1).unwrap()).unwrap().try_into().unwrap(); |
|
| 73 | + | let pinned = Client::at(Endpoint::tls(&url, old_pin).unwrap(), None); |
|
| 74 | + | assert!(pinned.send("GET", "/api/v1/instance", &[], vec![]).await.is_err()); |
|
| 75 | + | // ... until connect checks that the same instance key vouches for it. |
|
| 76 | + | let o = run(c, &["connect", &url]); |
|
| 77 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 78 | + | let cfg2 = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap(); |
|
| 79 | + | assert_ne!(cfg, cfg2, "re-pinned"); |
|
| 80 | + | srv.stop(); |
|
| 81 | + | ||
| 82 | + | let o = hermetic(s).args(["admin", "verify"]).output().unwrap(); |
|
| 83 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | #[test] |
|
| 87 | + | fn someone_elses_server_is_refused() { |
|
| 88 | + | let (sd, cd, md) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); |
|
| 89 | + | let (s, c, m) = (sd.path(), cd.path(), md.path()); |
|
| 90 | + | KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap(); |
|
| 91 | + | KeyPair::create_home(&m.join("owner"), "Mallory", "").unwrap(); |
|
| 92 | + | let (srv, port) = server_for(s, &m.join("owner")); |
|
| 93 | + | let o = run(c, &["connect", &format!("https://127.0.0.1:{port}")]); |
|
| 94 | + | assert!(!o.status.success()); |
|
| 95 | + | assert!(text(&o).contains("was set up for"), "{}", text(&o)); |
|
| 96 | + | srv.stop(); |
|
| 97 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: `spectra connect https://HOST[:PORT]` (#21).** Uses a Spectra on another machine. It trusts the server only when your own identity delegates to its instance key and that key vouches for the certificate it presents; until you have delegated, it saves the instance's public identity and prints the `oxy identity delegate add` to run. Then it pins the certificate in `~/.config/spectra/client.toml` (or `SPECTRA_SERVER`), claims a server set up with `spectra init --owner`, and brings the server up to your current identity. A certificate that changes is refused until `connect` checks it again. The stdio shim uses the configured server (with `--home`). |
|
| 5 | 6 | - **Added: setting a server up without the owner's key (#22).** `spectra init --owner KEYID` makes the instance key and the ledger with only the owner's KeyId named. Until that owner claims it, the daemon answers who it is and refuses everything else, saying it has no owner yet; the claim is the owner's identity signed by itself, and is accepted from no other key. A signed request the API refuses is now told why. |
|
| 6 | 7 | - **Added: serving other machines over TLS (#20).** With `[server] bind` beyond loopback (or `spectra serve --bind ADDR`), the daemon serves HTTPS with a certificate of its own, made on first start and kept in `keys/tls.{crt,key}`, or with `[server] tls = { cert, key }`. No certificate authority is involved: the instance key vouches for the certificate with a signed `instance.tls` event, and `GET /api/v1/instance` tells a client, before it trusts anything, who the instance is, whose it is and which certificate it signed. Clients pin that one certificate and check the server's handshake signature. An agent bound to a Unix user is refused over TCP and recorded as `guard.impersonation`, since a network connection has no uid to check. |
|
| 7 | 8 | - **Added: the README's quick start, a systemd user unit (`packaging/spectra.service`) and `tests/bench.rs`** for the targets of spec §24 (brief 2.7 ms, say 3.1 ms, wait wakes 4.2 ms after a mention, with 1,000 messages, in release). |
|
| … | 14 unchanged lines |
modified crates/spectra-core/src/daemon.rs +25 −13
| … | 201 unchanged lines |
||
| 202 | 202 | } |
|
| 203 | 203 | ||
| 204 | 204 | /// The owner claims a Spectra set up with `spectra init --owner` |
|
| 205 | - | /// (spec §8.6): their identity, signed by itself over its payload, and |
|
| 206 | - | /// accepted only when no owner is recorded and the key is the one named. |
|
| 207 | - | pub async fn claim(&self, w: WireEvent) -> Result<u64> { |
|
| 208 | - | let n = w.into_new_event()?; |
|
| 209 | - | if n.kind != "identity.owner" || n.actor != "human" { |
|
| 210 | - | bail!("a claim is the owner's identity.owner"); |
|
| 211 | - | } |
|
| 205 | + | /// (spec §8.6): the named identity, signed by itself over its payload, |
|
| 206 | + | /// then any successors up to the owner's current identity, each a |
|
| 207 | + | /// rotation as `identity refresh` makes. Accepted only while no owner |
|
| 208 | + | /// is recorded, and only from the key init named. |
|
| 209 | + | pub async fn claim(&self, events: Vec<WireEvent>) -> Result<Vec<u64>> { |
|
| 212 | 210 | let mut principals = self.principals.read().await.clone(); |
|
| 213 | 211 | if principals.owner().is_some() { |
|
| 214 | 212 | bail!("this Spectra already has an owner"); |
|
| 215 | 213 | } |
|
| 216 | 214 | let named = self.named_owner().context("this Spectra names no owner (spectra init --owner)")?; |
|
| 217 | - | let key = n.payload.get("key").and_then(|k| k.as_str()).unwrap_or("").to_ascii_lowercase(); |
|
| 218 | - | if key != named || n.auth.key.map(hex::encode) != Some(named.clone()) { |
|
| 219 | - | bail!("this Spectra was set up for another owner"); |
|
| 215 | + | let mut out = Vec::new(); |
|
| 216 | + | for (i, w) in events.into_iter().enumerate() { |
|
| 217 | + | let n = w.into_new_event()?; |
|
| 218 | + | let want = if i == 0 { "identity.owner" } else { "identity.rotated" }; |
|
| 219 | + | if n.kind != want || n.actor != "human" { |
|
| 220 | + | bail!("a claim is the owner's identity.owner, then their rotations"); |
|
| 221 | + | } |
|
| 222 | + | if i == 0 { |
|
| 223 | + | let key = n.payload.get("key").and_then(|k| k.as_str()).unwrap_or("").to_ascii_lowercase(); |
|
| 224 | + | if key != named || n.auth.key.map(hex::encode) != Some(named.clone()) { |
|
| 225 | + | bail!("this Spectra was set up for another owner"); |
|
| 226 | + | } |
|
| 227 | + | } |
|
| 228 | + | principals.check(&probe_event(&n)).map_err(|why| anyhow::anyhow!("refused the claim: {why}"))?; |
|
| 229 | + | out.push(n); |
|
| 230 | + | } |
|
| 231 | + | if out.is_empty() { |
|
| 232 | + | bail!("an empty claim"); |
|
| 220 | 233 | } |
|
| 221 | - | principals.check(&probe_event(&n)).map_err(|why| anyhow::anyhow!("refused the claim: {why}"))?; |
|
| 222 | - | let appended = self.ledger.append(vec![n]).await?; |
|
| 234 | + | let appended = self.ledger.append(out).await?; |
|
| 223 | 235 | let _ = self.ledger.checkpoint().await; |
|
| 224 | 236 | *self.principals.write().await = Principals::load(&self.ledger).await?; |
|
| 225 | - | Ok(appended.first().map(|e| e.seq).unwrap_or(0)) |
|
| 237 | + | Ok(appended.iter().map(|e| e.seq).collect()) |
|
| 226 | 238 | } |
|
| 227 | 239 | ||
| 228 | 240 | /// Write a checkpoint covering everything (at shutdown). |
|
| … | 75 unchanged lines |
modified crates/spectra-mcp/src/http.rs +7 −4
| … | 72 unchanged lines |
||
| 73 | 73 | let p = state.daemon.principals.read().await; |
|
| 74 | 74 | (p.instance().cloned(), p.owner().map(|o| o.hash().to_hex())) |
|
| 75 | 75 | }; |
|
| 76 | + | let owner_missing = owner.is_none(); |
|
| 76 | 77 | let Some(instance) = instance else { |
|
| 77 | 78 | return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(json!({ "title": "this Spectra has no instance key" }))).into_response(); |
|
| 78 | 79 | }; |
|
| … | 5 unchanged lines |
||
| 84 | 85 | "name": instance.name, |
|
| 85 | 86 | "instance": spectra_core::principals::identity_payload(&instance), |
|
| 86 | 87 | "owner": owner, |
|
| 88 | + | // Until claimed: the KeyId `spectra init --owner` named. |
|
| 89 | + | "named_owner": if owner_missing { state.daemon.named_owner() } else { None }, |
|
| 87 | 90 | "tls": tls, |
|
| 88 | 91 | })) |
|
| 89 | 92 | .into_response() |
|
| … | 3 unchanged lines |
||
| 93 | 96 | /// their identity (spec §8.6). The event proves itself: it is signed by |
|
| 94 | 97 | /// the identity it records, over its payload. |
|
| 95 | 98 | async fn claim(State(state): State<AppState>, body: Bytes) -> Response { |
|
| 96 | - | let w: WireEvent = match serde_json::from_slice(&body) { |
|
| 99 | + | let events: Vec<WireEvent> = match serde_json::from_slice(&body) { |
|
| 97 | 100 | Ok(v) => v, |
|
| 98 | - | Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not an event: {e}") }))).into_response(), |
|
| 101 | + | Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not a list of events: {e}") }))).into_response(), |
|
| 99 | 102 | }; |
|
| 100 | - | match state.daemon.claim(w).await { |
|
| 101 | - | Ok(seq) => axum::Json(json!({ "seq": seq })).into_response(), |
|
| 103 | + | match state.daemon.claim(events).await { |
|
| 104 | + | Ok(seqs) => axum::Json(json!({ "seqs": seqs })).into_response(), |
|
| 102 | 105 | Err(e) => (StatusCode::FORBIDDEN, axum::Json(json!({ "title": format!("{e:#}") }))).into_response(), |
|
| 103 | 106 | } |
|
| 104 | 107 | } |
|
| … | 158 unchanged lines |
modified docs/spec.md +1 −1
| … | 349 unchanged lines |
||
| 350 | 350 | ||
| 351 | 351 | Every later connection, from the CLI or a shim, accepts exactly that certificate. A changed certificate is refused until `spectra connect` runs again and the new one is signed by the same delegated instance key. `SPECTRA_SERVER` names a client configuration file for one command. |
|
| 352 | 352 | ||
| 353 | - | **Remote setup.** `spectra init --owner KEYID` on the server makes the instance key and the ledger without the owner's secret key: the ledger starts with `identity.instance`, and the daemon serves `/api/v1/instance` and nothing else until the owner claims it. The first `spectra connect` from the owner sends `identity.owner`, signed over its payload as every CLI event is (§9.5), and the daemon accepts it only from the key `--owner` named. Until then every other request is refused with the reason "this Spectra has no owner yet". |
|
| 353 | + | **Remote setup.** `spectra init --owner KEYID` on the server makes the instance key and the ledger without the owner's secret key: the ledger starts with `identity.instance`, and the daemon serves `/api/v1/instance` and nothing else until the owner claims it. The first `spectra connect` from the owner claims it: `identity.owner` for the identity `--owner` named, signed by it over its payload as every CLI event is (§9.5), followed by the `identity.rotated` that brings it to the owner's current identity (delegating to the instance key makes a new one). The daemon accepts the claim only while it has no owner and only for the named key, and `/api/v1/instance` says which key it waits for. Until then every other request is refused with the reason "this Spectra has no owner yet". |
|
| 354 | 354 | ||
| 355 | 355 | **A client has no ledger file.** With a server configured, every command goes through the JSON API (§18): reads, the owner's signed events (§8.5, "One writer"), `spectra open` and `spectra admin verify` (which the daemon runs and answers). `spectra admin check` and `rebuild` need the lock and run only on the server. A shim needs only the agent's home (`--home`) and the client configuration. |
|
| 356 | 356 | ||
| … | 671 unchanged lines |
modified src/cli/identity.rs +7 −0
| … | 133 unchanged lines |
||
| 134 | 134 | pub async fn refresh_event(ledger: &Ledger, home: &Path) -> Result<Option<spectra_core::event::NewEvent>> { |
|
| 135 | 135 | let p = Principals::load(ledger).await?; |
|
| 136 | 136 | let recorded = p.owner().context("no owner is recorded (spectra init)")?.clone(); |
|
| 137 | + | rotation(&recorded, home) |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | /// The `identity.rotated` that takes `recorded` to the owner's current |
|
| 141 | + | /// identity in `home`, with the identities between, or none if it is |
|
| 142 | + | /// current. |
|
| 143 | + | pub fn rotation(recorded: &Identity, home: &Path) -> Result<Option<spectra_core::event::NewEvent>> { |
|
| 137 | 144 | let pair = KeyPair::load_home(home)?; |
|
| 138 | 145 | if pair.key == recorded.hash() { |
|
| 139 | 146 | return Ok(None); |
|
| … | 89 unchanged lines |
modified src/cli/mcp.rs +4 −1
| … | 21 unchanged lines |
||
| 22 | 22 | pub async fn run(ctx: &Ctx, agent: &str, home: Option<PathBuf>) -> Result<i32> { |
|
| 23 | 23 | let home = match home { |
|
| 24 | 24 | Some(h) => h, |
|
| 25 | + | None if ctx.remote()?.is_some() => { |
|
| 26 | + | bail!("with Spectra on another machine, name the agent's home: spectra mcp --agent {agent} --home DIR") |
|
| 27 | + | } |
|
| 25 | 28 | None => { |
|
| 26 | 29 | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 27 | 30 | let h: Option<String> = |
|
| … | 5 unchanged lines |
||
| 33 | 36 | if key.identity.name != agent { |
|
| 34 | 37 | bail!("{} holds {}'s key, not {agent}'s", home.display(), key.identity.name); |
|
| 35 | 38 | } |
|
| 36 | - | let client = Arc::new(Client::new(&ctx.socket(), Some(key))); |
|
| 39 | + | let client = Arc::new(Client::at(ctx.endpoint()?, Some(key))); |
|
| 37 | 40 | let version = Arc::new(Mutex::new(STDIO_VERSIONS[0].to_string())); |
|
| 38 | 41 | let out = Arc::new(Mutex::new(tokio::io::stdout())); |
|
| 39 | 42 | let mut lines = BufReader::new(tokio::io::stdin()).lines(); |
|
| … | 54 unchanged lines |
modified src/cli/mod.rs +24 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | pub mod admin; |
|
| 6 | 6 | pub mod agent; |
|
| 7 | + | pub mod connect; |
|
| 7 | 8 | pub mod format; |
|
| 8 | 9 | pub mod human; |
|
| 9 | 10 | pub mod identity; |
|
| … | 45 unchanged lines |
||
| 55 | 56 | #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")] |
|
| 56 | 57 | owner: Option<String>, |
|
| 57 | 58 | }, |
|
| 59 | + | /// Use a Spectra on another machine: trust it through your delegation to |
|
| 60 | + | /// its instance key, pin its certificate, and claim it if it is new |
|
| 61 | + | Connect { |
|
| 62 | + | /// The server, https://HOST[:PORT] |
|
| 63 | + | url: String, |
|
| 64 | + | /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy) |
|
| 65 | + | #[arg(long)] |
|
| 66 | + | owner_home: Option<PathBuf>, |
|
| 67 | + | }, |
|
| 58 | 68 | /// The owner's and the instance's identities: show, refresh |
|
| 59 | 69 | Identity { |
|
| 60 | 70 | #[command(subcommand)] |
|
| … | 216 unchanged lines |
||
| 277 | 287 | /// The daemon's Unix socket. |
|
| 278 | 288 | pub fn socket(&self) -> PathBuf { |
|
| 279 | 289 | self.data.join("spectra.sock") |
|
| 290 | + | } |
|
| 291 | + | ||
| 292 | + | /// The Spectra on another machine this one uses, if any (spec §8.6). |
|
| 293 | + | pub fn remote(&self) -> Result<Option<connect::ClientCfg>> { |
|
| 294 | + | connect::load() |
|
| 295 | + | } |
|
| 296 | + | ||
| 297 | + | /// Where the daemon is: the configured server, else the local socket. |
|
| 298 | + | pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> { |
|
| 299 | + | Ok(match self.remote()? { |
|
| 300 | + | Some(c) => c.endpoint()?, |
|
| 301 | + | None => spectra_mcp::client::Endpoint::Unix(self.socket()), |
|
| 302 | + | }) |
|
| 280 | 303 | } |
|
| 281 | 304 | ||
| 282 | 305 | /// Somewhere to append the owner's signed events: straight to the |
|
| … | 77 unchanged lines |
||
| 360 | 383 | let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) }; |
|
| 361 | 384 | match cli.command { |
|
| 362 | 385 | Command::Version { json } => version(json), |
|
| 386 | + | Command::Connect { url, owner_home } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home)), |
|
| 363 | 387 | Command::Init { owner_home, name, owner: Some(key) } => { |
|
| 364 | 388 | let _ = owner_home; |
|
| 365 | 389 | runtime()?.block_on(identity::init_for(&ctx, &key, name)) |
|
| … | 63 unchanged lines |
modified tests/remote.rs +3 −2
| … | 12 unchanged lines |
||
| 13 | 13 | ||
| 14 | 14 | fn claim_body(pair: &KeyPair) -> Vec<u8> { |
|
| 15 | 15 | let e = signed_event(pair, "human", "identity.owner", identity_payload(&pair.identity)); |
|
| 16 | - | serde_json::to_vec(&WireEvent::from_new(&e)).unwrap() |
|
| 16 | + | serde_json::to_vec(&vec![WireEvent::from_new(&e)]).unwrap() |
|
| 17 | 17 | } |
|
| 18 | 18 | ||
| 19 | 19 | #[tokio::test] |
|
| … | 13 unchanged lines |
||
| 33 | 33 | let info = anon.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap(); |
|
| 34 | 34 | assert_eq!(info["name"], "earth"); |
|
| 35 | 35 | assert!(info["owner"].is_null(), "unclaimed"); |
|
| 36 | + | assert_eq!(info["named_owner"], owner.key.to_hex(), "says whose it waits for"); |
|
| 36 | 37 | let pin = probe.seen().unwrap().1; |
|
| 37 | 38 | let url = format!("https://127.0.0.1:{port}"); |
|
| 38 | 39 | ||
| … | 12 unchanged lines |
||
| 51 | 52 | // Nor can a claim naming the owner but signed by another key. |
|
| 52 | 53 | let mut forged = signed_event(&mallory, "human", "identity.owner", identity_payload(&owner.identity)); |
|
| 53 | 54 | forged.payload = identity_payload(&owner.identity); |
|
| 54 | - | let r = anon.send("POST", "/api/v1/claim", &[], serde_json::to_vec(&WireEvent::from_new(&forged)).unwrap()).await.unwrap(); |
|
| 55 | + | let r = anon.send("POST", "/api/v1/claim", &[], serde_json::to_vec(&vec![WireEvent::from_new(&forged)]).unwrap()).await.unwrap(); |
|
| 55 | 56 | assert_eq!(r.status, 403); |
|
| 56 | 57 | ||
| 57 | 58 | // The owner can, once. |
|
| … | 25 unchanged lines |