OXYGENHUB

Set a server up without the owner's key: spectra init --owner and the claim (#22) 77df591d5a2f

The daemon starts unowned, answers only who it is, and accepts the
owner's self-signed identity from the KeyId init named and no other.
Signed API requests that are refused are told why. A failing end-to-end
test no longer leaves its daemon running.

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 79c54a430d85
When
· 2026-11-03 13:25 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
77df591d5a2f981d0fff2d857c47f775cc5c408cb6f873ca52d9ae07c6d03b1f
Depends on
037c45d8093c 09752034b5a8 258bb3824813 36537112fcac 571572f5fb2c b576d05cf862 d17cbc7069ce e071e91a14ef
Depended on by
862f0db224aa 98a0fb9f0948 67bfa06a894f eca5af92279b
In bookmarks
main
In tags
v0.1.0
Edits
21 atoms, 1 tree operation, 9 files

Browse the tree at this change

added tests/remote.rs +82 −0
1 +
//! Setting a server up without the owner's secret key (spec §8.6,
2 +
//! spectra#22): `spectra init --owner KEYID`, nothing served but who it
3 +
//! is until the named owner claims it, and nobody else may.
4 +
5 +
mod common;
6 +
7 +
use common::{hermetic, run, text, Serving};
8 +
use spectra_core::daemon::WireEvent;
9 +
use spectra_core::keys::KeyPair;
10 +
use spectra_core::principals::{identity_payload, signed_event};
11 +
use spectra_mcp::client::{Client, Endpoint};
12 +
use spectra_mcp::tls::Pinned;
13 +
14 +
fn claim_body(pair: &KeyPair) -> Vec<u8> {
15 +
    let e = signed_event(pair, "human", "identity.owner", identity_payload(&pair.identity));
16 +
    serde_json::to_vec(&WireEvent::from_new(&e)).unwrap()
17 +
}
18 +
19 +
#[tokio::test]
20 +
async fn the_named_owner_claims_a_server_and_nobody_else_can() {
21 +
    let dir = tempfile::tempdir().unwrap();
22 +
    let d = dir.path();
23 +
    // The owner's key is on another machine: here there is only its KeyId.
24 +
    let owner = KeyPair::generate("Andy McCall", "");
25 +
    let o = run(d, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]);
26 +
    assert!(o.status.success(), "{}", text(&o));
27 +
    assert!(text(&o).contains("not yet claimed"), "{}", text(&o));
28 +
    assert!(!d.join("owner").exists(), "no owner home was read or made");
29 +
30 +
    let (srv, port) = Serving::start_tls(d);
31 +
    let probe = Pinned::new(None);
32 +
    let anon = Client::at(Endpoint::Tls { addr: format!("127.0.0.1:{port}"), config: probe.client_config().unwrap() }, None);
33 +
    let info = anon.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap();
34 +
    assert_eq!(info["name"], "earth");
35 +
    assert!(info["owner"].is_null(), "unclaimed");
36 +
    let pin = probe.seen().unwrap().1;
37 +
    let url = format!("https://127.0.0.1:{port}");
38 +
39 +
    // Before the claim, even the owner's signed requests are refused, and say why.
40 +
    let as_owner = Client::at(Endpoint::tls(&url, pin).unwrap(), Some(owner.clone()));
41 +
    let r = as_owner.send("GET", "/api/v1/agents", &[], vec![]).await.unwrap();
42 +
    assert_eq!(r.status, 401);
43 +
    assert!(String::from_utf8_lossy(&r.body).contains("no owner yet"), "{}", String::from_utf8_lossy(&r.body));
44 +
45 +
    // Someone else cannot claim it.
46 +
    let mallory = KeyPair::generate("Mallory", "");
47 +
    let r = anon.send("POST", "/api/v1/claim", &[], claim_body(&mallory)).await.unwrap();
48 +
    assert_eq!(r.status, 403);
49 +
    assert!(String::from_utf8_lossy(&r.body).contains("another owner"));
50 +
51 +
    // Nor can a claim naming the owner but signed by another key.
52 +
    let mut forged = signed_event(&mallory, "human", "identity.owner", identity_payload(&owner.identity));
53 +
    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 +
    assert_eq!(r.status, 403);
56 +
57 +
    // The owner can, once.
58 +
    let r = anon.send("POST", "/api/v1/claim", &[], claim_body(&owner)).await.unwrap();
59 +
    assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
60 +
    let r = as_owner.send("GET", "/api/v1/agents", &[], vec![]).await.unwrap();
61 +
    assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
62 +
    let r = anon.send("POST", "/api/v1/claim", &[], claim_body(&owner)).await.unwrap();
63 +
    assert_eq!(r.status, 403);
64 +
    assert!(String::from_utf8_lossy(&r.body).contains("already has an owner"));
65 +
    let info = anon.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap();
66 +
    assert_eq!(info["owner"], owner.key.to_hex());
67 +
    srv.stop();
68 +
69 +
    // An owner recorded after the instance verifies, and rebuilds.
70 +
    for args in [["admin", "verify"], ["admin", "rebuild"], ["admin", "check"]] {
71 +
        let o = hermetic(d).args(args).output().unwrap();
72 +
        assert!(o.status.success(), "{args:?}: {}", text(&o));
73 +
    }
74 +
}
75 +
76 +
#[test]
77 +
fn init_refuses_what_is_not_a_key_id() {
78 +
    let dir = tempfile::tempdir().unwrap();
79 +
    let o = run(dir.path(), &["init", "--owner", "andy"]);
80 +
    assert!(!o.status.success());
81 +
    assert!(text(&o).contains("not a KeyId"), "{}", text(&o));
82 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **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.
5 6
- **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.
6 7
- **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).
7 8
- **Added: the human at a terminal (#12).** `spectra say [-c CHANNEL] [--reply-to ID] BODY`, `spectra inbox [--follow]`, `spectra search QUERY [--channel] [--author] [--since 7d]` and `spectra api METHOD PATH [-d JSON]`, each signing with the owner's own key: through the daemon when it is serving, else straight into the ledger. Every listing takes `--json` (one document); `say` and `inbox --follow` make a terminal chat with the agents.
…
13 unchanged lines
modified crates/spectra-core/src/daemon.rs +34 −0
…
101 unchanged lines
102 102
        let now = crate::ids::now_ms();
103 103
        let r = {
104 104
            let p = self.principals.read().await;
105 +
            if p.owner().is_none() {
106 +
                return Err(Refusal::NotAPrincipal("this Spectra has no owner yet: its owner claims it with `spectra connect`".into()));
107 +
            }
105 108
            self.authn.authenticate(&p, proof, method, body, now, peer.map(|x| x.uid))
106 109
        };
107 110
        match &r {
…
81 unchanged lines
189 192
        // Who is who has changed: know it before the next request arrives.
190 193
        *self.principals.write().await = Principals::load(&self.ledger).await?;
191 194
        Ok(appended.iter().map(|e| e.seq).collect())
195 +
    }
196 +
197 +
    /// The owner `spectra init --owner` named, while none is recorded.
198 +
    pub fn named_owner(&self) -> Option<String> {
199 +
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
200 +
        let t: toml::Table = text.parse().ok()?;
201 +
        t.get("owner")?.get("key")?.as_str().map(|k| k.to_ascii_lowercase())
202 +
    }
203 +
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 +
        }
212 +
        let mut principals = self.principals.read().await.clone();
213 +
        if principals.owner().is_some() {
214 +
            bail!("this Spectra already has an owner");
215 +
        }
216 +
        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");
220 +
        }
221 +
        principals.check(&probe_event(&n)).map_err(|why| anyhow::anyhow!("refused the claim: {why}"))?;
222 +
        let appended = self.ledger.append(vec![n]).await?;
223 +
        let _ = self.ledger.checkpoint().await;
224 +
        *self.principals.write().await = Principals::load(&self.ledger).await?;
225 +
        Ok(appended.first().map(|e| e.seq).unwrap_or(0))
192 226
    }
193 227
194 228
    /// Write a checkpoint covering everything (at shutdown).
…
75 unchanged lines
modified crates/spectra-mcp/src/api.rs +15 −7
…
2 unchanged lines
3 3
//! may instead carry the owner's Radar session. Errors are RFC 7807
4 4
//! problems; lists page with `?after=`; reads carry an ETag.
5 5
6 -
use crate::http::{authenticate, AppState, PeerInfo};
6 +
use crate::http::{AppState, PeerInfo};
7 7
use axum::body::Bytes;
8 8
use axum::extract::connect_info::ConnectInfo;
9 9
use axum::extract::{Path, Query, State};
…
86 unchanged lines
96 96
            });
97 97
        }
98 98
    }
99 -
    authenticate(st, peer, headers, method_path, body).await.map_err(|_| {
100 -
        problem(
101 -
            StatusCode::UNAUTHORIZED,
102 -
            "not authenticated",
103 -
            Some("sign the request with your key (spectra's client does), or open Radar with `spectra open`".into()),
104 -
        )
99 +
    let signed = headers.get("spectra-key").is_some();
100 +
    crate::http::authenticate_why(st, peer, headers, method_path, body).await.map_err(|(status, why)| {
101 +
        if status == StatusCode::FORBIDDEN {
102 +
            problem(status, "this origin may not call Spectra", None)
103 +
        } else if signed {
104 +
            // A signed request is told why its signature was not enough.
105 +
            problem(StatusCode::UNAUTHORIZED, "not authenticated", Some(why))
106 +
        } else {
107 +
            problem(
108 +
                StatusCode::UNAUTHORIZED,
109 +
                "not authenticated",
110 +
                Some("sign the request with your key (spectra's client does), or open Radar with `spectra open`".into()),
111 +
            )
112 +
        }
105 113
    })
106 114
}
107 115
…
366 unchanged lines
modified crates/spectra-mcp/src/http.rs +38 −13
…
60 unchanged lines
61 61
        .route("/mcp", post(mcp).get(not_allowed).delete(not_allowed))
62 62
        .route("/api/v1/ledger/events", post(owner_events))
63 63
        .route("/api/v1/instance", axum::routing::get(instance))
64 +
        .route("/api/v1/claim", post(claim))
64 65
        .with_state(state)
65 66
}
66 67
…
21 unchanged lines
88 89
    .into_response()
89 90
}
90 91
92 +
/// `POST /api/v1/claim`: the owner named by `spectra init --owner` records
93 +
/// their identity (spec §8.6). The event proves itself: it is signed by
94 +
/// the identity it records, over its payload.
95 +
async fn claim(State(state): State<AppState>, body: Bytes) -> Response {
96 +
    let w: WireEvent = match serde_json::from_slice(&body) {
97 +
        Ok(v) => v,
98 +
        Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not an event: {e}") }))).into_response(),
99 +
    };
100 +
    match state.daemon.claim(w).await {
101 +
        Ok(seq) => axum::Json(json!({ "seq": seq })).into_response(),
102 +
        Err(e) => (StatusCode::FORBIDDEN, axum::Json(json!({ "title": format!("{e:#}") }))).into_response(),
103 +
    }
104 +
}
105 +
91 106
async fn not_allowed() -> StatusCode {
92 107
    StatusCode::METHOD_NOT_ALLOWED
93 108
}
…
20 unchanged lines
114 129
    }
115 130
}
116 131
117 -
/// Authenticate a request, or the response that refuses it.
118 -
pub async fn authenticate(
132 +
/// Authenticate a request, or the status and reason that refuse it.
133 +
pub async fn authenticate_why(
119 134
    state: &AppState,
120 135
    peer: PeerInfo,
121 136
    headers: &HeaderMap,
122 137
    method_path: &str,
123 138
    body: &[u8],
124 -
) -> Result<Caller, Response> {
139 +
) -> Result<Caller, (StatusCode, String)> {
125 140
    if !origin_ok(headers, &state.origins) {
126 -
        return Err((
127 -
            StatusCode::FORBIDDEN,
128 -
            axum::Json(protocol::error(&Json::Null, protocol::INVALID_REQUEST, "this origin may not call Spectra")),
129 -
        )
130 -
            .into_response());
141 +
        return Err((StatusCode::FORBIDDEN, "this origin may not call Spectra".into()));
131 142
    }
132 143
    let proof = proof_from(headers, method_path, body);
133 144
    state.daemon.authenticate(proof.as_ref(), method_path, body, peer.0).await.map_err(|why| {
134 145
        tracing::info!(%why, "refused a request");
135 146
        // The reason concerns the caller's own key, so the caller is told it:
136 147
        // an agent whose delegation expired must be able to say so.
137 -
        (
138 -
            StatusCode::UNAUTHORIZED,
139 -
            axum::Json(protocol::error(&Json::Null, protocol::UNAUTHENTICATED, &format!("not authenticated: {why}"))),
140 -
        )
141 -
            .into_response()
148 +
        (StatusCode::UNAUTHORIZED, why.to_string())
149 +
    })
150 +
}
151 +
152 +
/// Authenticate a request, or the JSON-RPC response that refuses it.
153 +
pub async fn authenticate(
154 +
    state: &AppState,
155 +
    peer: PeerInfo,
156 +
    headers: &HeaderMap,
157 +
    method_path: &str,
158 +
    body: &[u8],
159 +
) -> Result<Caller, Response> {
160 +
    authenticate_why(state, peer, headers, method_path, body).await.map_err(|(status, why)| {
161 +
        let (code, msg) = if status == StatusCode::FORBIDDEN {
162 +
            (protocol::INVALID_REQUEST, why)
163 +
        } else {
164 +
            (protocol::UNAUTHENTICATED, format!("not authenticated: {why}"))
165 +
        };
166 +
        (status, axum::Json(protocol::error(&Json::Null, code, &msg))).into_response()
142 167
    })
143 168
}
144 169
…
93 unchanged lines
modified docs/plan.md +1 −1
…
30 unchanged lines
31 31
## Sprint 2: client and server
32 32
33 33
- [x] #20 Server over TLS — done 2026-11-03: own certificate in keys/tls.*, vouched for by a signed instance.tls event; GET /api/v1/instance; pinned client transport; a uid-bound agent refused over TCP; tests/tls.rs.
34 -
- [ ] #22 Remote setup: spectra init --owner KEYID and the owner's claim from the client.
34 +
- [x] #22 Remote setup — done 2026-11-03: spectra init --owner KEYID; unowned daemon refuses all but /api/v1/instance and POST /api/v1/claim, which takes only the named key; tests/remote.rs. Found: a failing end-to-end test left its daemon running, which held the output open and looked like a hang; Serving now kills it on drop.
35 35
- [ ] #21 spectra connect and the TLS client: trust through the owner's delegation, pinned certificate, shims over the network.
36 36
- [ ] #23 The CLI without a ledger file: every command through the JSON API.
37 37
- [ ] #24 Radar from a workstation: spectra open's loopback proxy to the pinned server.
…
19 unchanged lines
modified src/cli/identity.rs +42 −0
…
172 unchanged lines
173 173
        .with_context(|| format!("the recorded owner's key is not in {}", home.display()))
174 174
}
175 175
176 +
/// `spectra init --owner KEYID` (spec §8.6): a server's instance key and
177 +
/// ledger, with the owner named but not yet recorded; the owner claims it
178 +
/// from their own machine, so their secret key never comes here.
179 +
pub async fn init_for(ctx: &Ctx, owner_key: &str, name: Option<String>) -> Result<i32> {
180 +
    let key = owner_key.trim().to_ascii_lowercase();
181 +
    if key.len() != 64 || hex::decode(&key).is_err() {
182 +
        bail!("{owner_key} is not a KeyId (64 hex digits; the owner's `oxy identity show` prints it)");
183 +
    }
184 +
    let data = &ctx.data;
185 +
    let db = data.join("spectra.db");
186 +
    if db.exists() {
187 +
        bail!("{} already holds a Spectra (spectra identity show)", data.display());
188 +
    }
189 +
    std::fs::create_dir_all(data)?;
190 +
    #[cfg(unix)]
191 +
    {
192 +
        use std::os::unix::fs::PermissionsExt;
193 +
        std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?;
194 +
    }
195 +
    let name = name.unwrap_or_else(hostname);
196 +
    let instance = KeyPair::instance(data, &name)?;
197 +
    let ledger = Ledger::open(&db, Some(instance.clone())).await?;
198 +
    let mut first = vec![signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity))];
199 +
    first.extend(spectra_core::conversation::default_channel_events());
200 +
    ledger.append(first).await?;
201 +
    ledger.checkpoint().await?;
202 +
    std::fs::write(
203 +
        data.join("spectra.toml"),
204 +
        format!(
205 +
            "# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{key}\"     # claimed from the owner's machine with `spectra connect`\n\n[server]\n# bind = \"[::]:7350\"   # serve other machines over TLS (spec §8.6)\n"
206 +
        ),
207 +
    )?;
208 +
    println!("{} {}  {}", ui::done("Initialised"), ui::subject("Spectra"), ui::path(data.display()));
209 +
    println!("{}", ui::kv("owner", format!("{}{}", ui::hash(&key[..12]), ui::dim(" (named; not yet claimed)"))));
210 +
    println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12)))));
211 +
    println!();
212 +
    println!("  {} {}", ui::note(), ui::dim("serve it to other machines: set [server] bind in spectra.toml, then spectra serve"));
213 +
    println!("  {} {}", ui::note(), ui::dim("then, on the owner's machine:"));
214 +
    println!("    {}", ui::id("spectra connect https://HOST:7350"));
215 +
    Ok(0)
216 +
}
217 +
176 218
fn hostname() -> String {
177 219
    std::fs::read_to_string("/etc/hostname")
178 220
        .ok()
…
8 unchanged lines
modified src/cli/mod.rs +10 −1
…
48 unchanged lines
49 49
        /// This instance's name (default: the host name)
50 50
        #[arg(long)]
51 51
        name: Option<String>,
52 +
        /// Set up a server without the owner's secret key: name the owner's
53 +
        /// KeyId, and they claim it from their machine with `spectra
54 +
        /// connect` (spec §8.6)
55 +
        #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")]
56 +
        owner: Option<String>,
52 57
    },
53 58
    /// The owner's and the instance's identities: show, refresh
54 59
    Identity {
…
300 unchanged lines
355 360
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
356 361
    match cli.command {
357 362
        Command::Version { json } => version(json),
358 -
        Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
363 +
        Command::Init { owner_home, name, owner: Some(key) } => {
364 +
            let _ = owner_home;
365 +
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
366 +
        }
367 +
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
359 368
        Command::Identity { command } => runtime()?.block_on(async {
360 369
            match command {
361 370
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
…
58 unchanged lines
modified tests/common/mod.rs +11 −0
…
100 unchanged lines
101 101
    }
102 102
}
103 103
104 +
/// A test that fails mid-way must not leave its daemon running: an orphan
105 +
/// holds the test's output open and looks like a hang.
106 +
impl Drop for Serving {
107 +
    fn drop(&mut self) {
108 +
        if let Ok(None) = self.child.try_wait() {
109 +
            let _ = self.child.kill();
110 +
            let _ = self.child.wait();
111 +
        }
112 +
    }
113 +
}
114 +
104 115
pub fn setup() -> tempfile::TempDir {
105 116
    let dir = tempfile::tempdir().unwrap();
106 117
    let d = dir.path();
…
6 unchanged lines